Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,826 @@
# Подробная инструкция по развертыванию и запуску HAN Chat
Эта инструкция описывает первый запуск **текущего legacy/stub проекта ВМ1** на одной виртуальной
машине с Ubuntu 24.04. Она не разворачивает target ВМ2 Processing и не подтверждает production-готовность Message Safety v2. Все команды предполагают, что проект расположен в
`/opt/han-chat/backend`, а команды Docker Compose выполняются из этого каталога.
PostgreSQL и Selectel S3 не запускаются в Docker Compose: их необходимо создать
заранее как внешние управляемые сервисы. Из интернета должны быть доступны только
порты 80 и 443 виртуальной машины.
## 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`.
@@ -0,0 +1,330 @@
# HAN Chat production-like deployment runbook
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.
## 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.
@@ -0,0 +1,271 @@
# Инструкция по развертыванию HAN Chat в production-like окружении
Это исполняемый чек-лист для контура на одной виртуальной машине. PostgreSQL и S3
используются как внешние управляемые сервисы. Во время развертывания запрещено
использовать `docker compose down -v`, откат миграций Alembic и изменяемые теги образов.
Подробная пошаговая инструкция для первого запуска находится в
`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, чтобы сообщение
с неопределенным статусом не было отправлено повторно.
@@ -0,0 +1,51 @@
schema_version: 1
settings:
auth.phone.enabled: {type: boolean, value: true, public: true}
auth.password.enabled: {type: boolean, value: false, public: true}
otp.phone.max_send_attempts_per_24h: {type: integer, value: 3, public: false}
otp.phone.min_seconds_between_attempts: {type: integer, value: 30, public: false}
otp.phone.max_verify_attempts: {type: integer, value: 5, public: false}
otp.phone.code_length: {type: integer, value: 6, public: false}
otp.phone.ttl_seconds: {type: integer, value: 60, public: false}
otp.phone.sms_order_timeout_ms: {type: integer, value: 3000, public: false}
operator.call.phone: {type: string, value: "+74999591007", public: true}
consent.personal_data.required: {type: boolean, value: true, public: true}
consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true}
consent.personal_data.version: {type: string, value: "2026-06-10", public: true}
consent.privacy_policy.document_url: {type: string, value: "https://www.han0107.ru/privacy", public: true}
consent.user_agreement.required: {type: boolean, value: true, public: true}
consent.user_agreement.document_url: {type: string, value: "https://www.han0107.ru/user-agreement", public: true}
consent.user_agreement.version: {type: string, value: "2026-06-10", public: true}
consent.marketing.required: {type: boolean, value: false, public: true}
consent.marketing.document_url: {type: string, value: "https://www.han0107.ru/privacy/ads-agree", public: true}
consent.marketing.version: {type: string, value: "2026-06-10", public: true}
chat.message.max_length: {type: integer, value: 4000, public: true}
chat.attachments.allowed_extensions: {type: string_list, value: "jpg,jpeg,png,webp,heic,heif,pdf", public: true}
chat.attachments.allowed_mime_types: {type: string_list, value: "image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf", public: true}
chat.attachments.disallowed_extensions: {type: string_list, value: "svg,doc,docx,xls,xlsx,csv", public: false}
chat.attachments.max_size_mb: {type: integer, value: 5, public: true}
chat.attachments.storage: {type: string, value: selectel_s3, public: false}
chat.attachments.upload_mode: {type: string, value: presigned_put, public: false}
chat.attachments.safety_scan_required: {type: boolean, value: true, public: false}
chat.attachments.presigned_upload_ttl_seconds: {type: integer, value: 600, public: true}
rate_limit.message_send.per_user: {type: string, value: "30/minute", public: true}
rate_limit.message_send.per_dialog: {type: string, value: "20/minute", public: false}
rate_limit.download_url.per_user: {type: string, value: "60/hour", public: false}
rate_limit.public_endpoints.per_ip: {type: string, value: "60/minute", public: true}
rate_limit.login.per_ip: {type: string, value: "10/minute", public: true}
rate_limit.notifications_read.per_user: {type: string, value: "120/minute", public: false}
rate_limit.notifications_action.per_user: {type: string, value: "60/minute", public: false}
rate_limit.notification_upload.per_user: {type: string, value: "20/minute", public: false}
rate_limit.notifications_public.per_ip: {type: string, value: "60/minute", public: false}
notification.home.max_items: {type: integer, value: 7, public: false}
notification.center.max_items: {type: integer, value: 15, public: false}
notification.carousel.autoplay_enabled: {type: boolean, value: false, public: true}
notification.carousel.autoplay_interval_ms: {type: integer, value: 5000, public: true}
notification.hidden.default_ttl_days: {type: integer, value: 3, public: false}
notification.documents.max_files: {type: integer, value: 10, public: false}
notification.instruction.allowed_hosts: {type: string_list, value: "chat.example.ru", public: false}
notification.expire_job.run_at: {type: string, value: "00:01", public: false}
notification.upload_draft.ttl_days: {type: integer, value: 7, public: false}
ux.session.idle_timeout_minutes: {type: integer, value: 30, public: true}
security.cors.allowed_origins: {type: string_list, value: "https://chat.example.ru", public: false}
security.public_cache.max_age_seconds: {type: integer, value: 3600, public: false}
@@ -0,0 +1,147 @@
x-api-job-secrets: &api-job-secrets
- api_database_url
- api_redis_url
- api_redis_realtime_url
- message_safety_service_token
- bitrix_local_app_internal_token
- bitrix_api_inbox_token
- keycloak_settings_bridge_token
- selectel_s3_access_key
- selectel_s3_secret_key
- cursor_hmac_secret
x-api-job-environment: &api-job-environment
HAN_SECRET_VARS: >-
DATABASE_URL REDIS_URL REDIS_REALTIME_URL MESSAGE_SAFETY_SERVICE_TOKEN
BITRIX_LOCAL_APP_INTERNAL_TOKEN BITRIX_API_INBOX_TOKEN
KEYCLOAK_SETTINGS_BRIDGE_TOKEN SELECTEL_S3_ACCESS_KEY SELECTEL_S3_SECRET_KEY
CURSOR_HMAC_SECRET
DATABASE_URL_FILE: /run/secrets/api_database_url
REDIS_URL_FILE: /run/secrets/api_redis_url
REDIS_REALTIME_URL_FILE: /run/secrets/api_redis_realtime_url
MESSAGE_SAFETY_SERVICE_TOKEN_FILE: /run/secrets/message_safety_service_token
BITRIX_LOCAL_APP_INTERNAL_TOKEN_FILE: /run/secrets/bitrix_local_app_internal_token
BITRIX_API_INBOX_TOKEN_FILE: /run/secrets/bitrix_api_inbox_token
KEYCLOAK_SETTINGS_BRIDGE_TOKEN_FILE: /run/secrets/keycloak_settings_bridge_token
SELECTEL_S3_ACCESS_KEY_FILE: /run/secrets/selectel_s3_access_key
SELECTEL_S3_SECRET_KEY_FILE: /run/secrets/selectel_s3_secret_key
CURSOR_HMAC_SECRET_FILE: /run/secrets/cursor_hmac_secret
APP_ENV: ${APP_ENV:-production-like}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL}
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_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}
MESSAGE_SAFETY_TASK_POLL_MAX_SEC: ${MESSAGE_SAFETY_TASK_POLL_MAX_SEC:-300}
BITRIX_LOCAL_APP_BASE_URL: ${BITRIX_LOCAL_APP_BASE_URL:-http://bitrix-local-app:8080}
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC: ${BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC:-20}
SELECTEL_S3_ENDPOINT_URL: ${SELECTEL_S3_ENDPOINT_URL}
SELECTEL_S3_BUCKET_DOCUMENTS: ${SELECTEL_S3_BUCKET_DOCUMENTS}
SELECTEL_S3_BUCKET_ATTACHMENTS: ${SELECTEL_S3_BUCKET_ATTACHMENTS}
SELECTEL_S3_BUCKET_QUARANTINE: ${SELECTEL_S3_BUCKET_QUARANTINE}
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32}
services:
migrate-api:
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
profiles: ["ops"]
environment:
HAN_SECRET_VARS: DATABASE_URL
DATABASE_URL_FILE: /run/secrets/api_database_url
secrets:
- api_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-bitrix-local:
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
profiles: ["ops"]
environment:
HAN_SECRET_VARS: BITRIX_DATABASE_URL
BITRIX_DATABASE_URL_FILE: /run/secrets/bitrix_database_url
secrets:
- bitrix_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-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}
profiles: ["ops"]
environment:
HAN_SECRET_VARS: SMS_DATABASE_URL
SMS_DATABASE_URL_FILE: /run/secrets/sms_database_url
secrets:
- sms_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}
seed-settings:
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
profiles: ["ops"]
environment: *api-job-environment
secrets: *api-job-secrets
command:
- /bin/sh
- -ec
- >-
python -m app.cli.seed_settings
--file /deployment/app-settings.production-like.yaml
&& python -m app.cli.validate_settings
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
- ${MESSAGE_SAFETY_CA_HOST_PATH}:/run/config/message-safety-internal-ca.pem:ro
- ./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
profiles: ["ops"]
entrypoint: ["sleep", "infinity"]
networks: [backend, observability, egress]
restart: "no"
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${CONFIG_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
if [ -z "${PG_BACKUP_DSN_FILE:-}" ] || [ ! -r "$PG_BACKUP_DSN_FILE" ]; then
echo "PG_BACKUP_DSN_FILE is required from the secret launcher." >&2
exit 64
fi
PG_BACKUP_DSN=$(cat "$PG_BACKUP_DSN_FILE")
case "$PG_BACKUP_DSN" in
*sslmode=verify-full*sslrootcert=*) ;;
*) echo "PG_BACKUP_DSN must enforce sslmode=verify-full and sslrootcert." >&2; exit 64 ;;
esac
output_dir=${1:-/opt/han-chat/backups}
umask 077
mkdir -p "$output_dir"
stamp=$(date -u +%Y%m%dT%H%M%SZ)
archive="$output_dir/han-chat-$stamp.dump"
PGDATABASE="$PG_BACKUP_DSN" pg_dump \
--format=custom --no-owner --no-privileges --file="$archive"
unset PG_BACKUP_DSN
pg_restore --list "$archive" >/dev/null
sha256sum "$archive" > "$archive.sha256"
chmod 600 "$archive" "$archive.sha256"
echo "Logical backup verified: $archive"
echo "This supplements, but does not replace, provider backup/PITR and restore rehearsal."
@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${CONFIG_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
if [ "${PITR_MARKER_CONFIRMED:-false}" != "true" ]; then
echo "Refusing migration: create provider PITR marker, then set PITR_MARKER_CONFIRMED=true" >&2
exit 64
fi
if [ -n "${HAN_RUNTIME_SECRET_MANIFEST:-}" ]; then
./scripts/validate-env "$CONFIG_FILE" --runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
else
./scripts/validate-env "$CONFIG_FILE" --runtime-env
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,34 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${CONFIG_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
previous_release=${1:-}
if [ -z "$previous_release" ]; then
echo "Usage: $0 <previous-immutable-release>" >&2
exit 64
fi
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
if [ "${SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED:-false}" != "true" ]; then
echo "Refusing rollback: set SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true after migration review." >&2
exit 64
fi
if [ -n "${HAN_RUNTIME_SECRET_MANIFEST:-}" ]; then
./scripts/validate-env "$CONFIG_FILE" --runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
else
./scripts/validate-env "$CONFIG_FILE" --runtime-env
fi
RELEASE_VERSION="$previous_release" docker compose --env-file "$CONFIG_FILE" config --quiet
RELEASE_VERSION="$previous_release" docker compose --env-file "$CONFIG_FILE" up -d --remove-orphans
docker compose --env-file "$CONFIG_FILE" ps
echo "Application images rolled back without Alembic downgrade."
echo "Run deployment/scripts/smoke.sh and verify outbox/inbox idempotency."
@@ -0,0 +1,448 @@
#!/usr/bin/env bash
# Создаёт 9 персональных уведомлений всех видов контура P для одного user_id.
# Запускать на ВМ из каталога backend: /opt/han-chat/backend
#
# cd /opt/han-chat/backend
# sed -i 's/\r$//' deployment/scripts/seed-personal-notifications-test.sh
# chmod +x deployment/scripts/seed-personal-notifications-test.sh
# ./deployment/scripts/seed-personal-notifications-test.sh
#
# Токен берётся из .env (NOTIFICATIONS_TOKEN_PRODUCER_TEST) — тот же, что у api-backend.
# При старте api-backend синхронизирует hash токена в notification_sources.
set -euo pipefail
cd "$(dirname "$0")/../.."
ENV_FILE="${ENV_FILE:-.env}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Не найден $ENV_FILE. Запускайте из каталога backend." >&2
exit 1
fi
env_value() {
python3 - "$ENV_FILE" "$1" <<'PY'
import sys
from pathlib import Path
path, wanted = 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)
break
else:
raise SystemExit(f"missing environment variable: {wanted}")
PY
}
trim_token() {
printf '%s' "$1" | tr -d '\r\n\t '
}
TOKEN="$(trim_token "${NOTIFICATIONS_TOKEN_PRODUCER_TEST:-}")"
if [[ -z "$TOKEN" ]]; then
TOKEN="$(trim_token "$(env_value NOTIFICATIONS_TOKEN_PRODUCER_TEST 2>/dev/null || true)")"
fi
if [[ -z "$TOKEN" ]]; then
read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (из .env не найден): " TOKEN
echo
TOKEN="$(trim_token "$TOKEN")"
fi
read -rp "USER_ID клиента: " USER_ID
USER_ID="$(trim_token "$USER_ID")"
if [[ -z "${TOKEN}" || -z "${USER_ID}" ]]; then
echo "TOKEN и USER_ID обязательны." >&2
exit 1
fi
echo "Токен: ${#TOKEN} символов (первые 8: ${TOKEN:0:8}…)"
PAYLOAD_DIR="$(mktemp -d)"
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
BASE_TS="$(date +%s)"
RUN_ID="manual-test-${BASE_TS}"
NOW="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
DT_URGENT="$(date -u -d "${NOW} +0 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+0S +"%Y-%m-%dT%H:%M:%SZ")"
DT_PAYMENT="$(date -u -d "${NOW} - 60 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-60S +"%Y-%m-%dT%H:%M:%SZ")"
DT_DOCS_REQ="$(date -u -d "${NOW} - 120 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-120S +"%Y-%m-%dT%H:%M:%SZ")"
DT_DOCS_READY="$(date -u -d "${NOW} - 180 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-180S +"%Y-%m-%dT%H:%M:%SZ")"
DT_STATUS="$(date -u -d "${NOW} - 240 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-240S +"%Y-%m-%dT%H:%M:%SZ")"
DT_REMINDER="$(date -u -d "${NOW} - 300 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-300S +"%Y-%m-%dT%H:%M:%SZ")"
DT_NEWS="$(date -u -d "${NOW} - 360 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-360S +"%Y-%m-%dT%H:%M:%SZ")"
DT_PROMO="$(date -u -d "${NOW} - 420 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-420S +"%Y-%m-%dT%H:%M:%SZ")"
DT_ADS="$(date -u -d "${NOW} - 480 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-480S +"%Y-%m-%dT%H:%M:%SZ")"
DEADLINE_URGENT="$(date -u -d "${NOW} + 2 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+2d +"%Y-%m-%dT%H:%M:%SZ")"
DEADLINE_DOCS="$(date -u -d "${NOW} + 5 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+5d +"%Y-%m-%dT%H:%M:%SZ")"
DEADLINE_REMINDER="$(date -u -d "${NOW} + 1 day" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+1d +"%Y-%m-%dT%H:%M:%SZ")"
EXPIRE_PAYMENT="$(date -u -d "${NOW} + 3 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+3d +"%Y-%m-%dT%H:%M:%SZ")"
write_payload() {
local name="$1"
cat > "${PAYLOAD_DIR}/${name}.json"
}
create_notification() {
local label="$1"
local payload_file="${PAYLOAD_DIR}/${label}.json"
if [[ ! -f "$payload_file" ]]; then
echo "Нет payload: ${payload_file}" >&2
return 1
fi
echo
echo "========== ${label} =========="
docker run --rm \
--network han-chat-backend \
-v "${payload_file}:/payload.json:ro" \
curlimages/curl:latest \
-sS -i \
-X POST \
'http://api-backend:8000/internal/notifications/v1/notifications' \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json; charset=utf-8' \
--data-binary @/payload.json
}
prepare_docs_in_s3() {
echo >&2
echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2
docker compose --env-file "$ENV_FILE" exec -T \
-e "USER_ID=${USER_ID}" \
-e "RUN_ID=${RUN_ID}" \
api-backend python3 - <<'PY'
import hashlib
import os
import uuid
import boto3
from botocore.client import Config
from app.settings import Settings
USER_ID = os.environ["USER_ID"]
settings = Settings()
client = boto3.client(
"s3",
endpoint_url=str(settings.selectel_s3_endpoint_url),
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
config=Config(signature_version="s3v4", s3={"addressing_style": "virtual"}),
)
bucket = settings.selectel_s3_bucket_documents
fixtures = [
{
"prefix": "CONTRACT",
"title": "Уведомление о постановке на миграционный учёт.pdf",
"body": b"""%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj
xref
0 4
trailer<</Size 4/Root 1 0 R>>
startxref
100
%%EOF
""",
},
{
"prefix": "RESULTS",
"title": "Справка о соблюдении миграционного законодательства.pdf",
"body": b"""%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj
xref
0 4
trailer<</Size 4/Root 1 0 R>>
startxref
100
%%EOF
""",
},
]
lines: list[str] = []
for item in fixtures:
doc_id = str(uuid.uuid4())
key = f"documents/users/{USER_ID}/{doc_id}"
body = item["body"]
checksum = hashlib.sha256(body).hexdigest()
client.put_object(
Bucket=bucket,
Key=key,
Body=body,
ContentType="application/pdf",
)
p = item["prefix"]
lines.append(
f"{p}_KEY={key}\n"
f"{p}_TITLE={item['title']}\n"
f"{p}_SIZE={len(body)}\n"
f"{p}_SHA256={checksum}"
)
print("\n".join(lines))
PY
}
echo "Run ID: ${RUN_ID}"
echo "User ID: ${USER_ID}"
read -rp "Подготовить документы в S3 для docs_ready? [Y/n]: " PREP_DOCS
PREP_DOCS="${PREP_DOCS:-Y}"
CONTRACT_KEY=""
CONTRACT_TITLE=""
CONTRACT_SIZE=""
CONTRACT_SHA256=""
RESULTS_KEY=""
RESULTS_TITLE=""
RESULTS_SIZE=""
RESULTS_SHA256=""
if [[ "${PREP_DOCS^^}" != "N" ]]; then
PREP_OUT="$(prepare_docs_in_s3)"
echo "${PREP_OUT}"
while IFS= read -r line; do
[[ "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]] || continue
declare "${BASH_REMATCH[1]}=${BASH_REMATCH[2]}"
done <<< "${PREP_OUT}"
fi
# --- payloads ---
write_payload urgent <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "urgent",
"source": "producer_test",
"external_id": "${RUN_ID}-urgent",
"notification_datetime": "${DT_URGENT}",
"header": "Истекает срок постановки на учёт",
"text": "По данным сервиса, срок уведомления о месте пребывания истекает 29 июля — просрочка влечёт административную ответственность.",
"priority_override": 1,
"date_expired": "${DEADLINE_URGENT}",
"details": {
"deadline": "${DEADLINE_URGENT}",
"details_header": "Срочно: соблюдение сроков миграционного учёта",
"details_text": "Федеральный закон № 109-ФЗ обязывает иностранного гражданина в течение 7 рабочих дней с даты въезда подать уведомление о прибытии в место пребывания (если иное не предусмотрено для вашего правового статуса).\n\nПо имеющимся данным крайний срок для вашего случая — 29.07.2026. Нарушение сроков может повлечь штраф от 2 000 до 5 000 ₽ и, при повторном нарушении, более серьёзные последствия, включая административное выдворение.\n\nЕсли уведомление уже подано — отметьте «Готово»; если нужна помощь — напишите оператору в чат.",
"todo_header": "Что проверить сейчас",
"todo_plan": [
{"number": 1, "text": "Сверьте дату въезда и адрес фактического проживания в анкете личного кабинета."},
{"number": 2, "text": "Подготовьте копии паспорта, миграционной карты и документа о праве пребывания (виза, РВП, ВНЖ, патент)."},
{"number": 3, "text": "Нажмите «Готово», если уведомление уже подано через МВД или принимающую сторону."},
{"number": 4, "text": "При сомнениях выберите «Сделаю позже» и задайте вопрос оператору — укажите город и тип документа."}
]
}
}
JSON
write_payload payment_pending <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "payment_pending",
"source": "producer_test",
"external_id": "${RUN_ID}-payment_pending",
"notification_datetime": "${DT_PAYMENT}",
"header": "Оплата сопровождения по миграционному учёту",
"text": "Счёт №МИГ-2026-0718 на 18 500 ₽ за подготовку пакета и подачу уведомления — оплатите до 30 июля.",
"price": "18500.00",
"old_price": "22000.00",
"date_expired": "${EXPIRE_PAYMENT}",
"payment_url": "https://pay.han0107.ru/checkout/test-${RUN_ID}"
}
JSON
write_payload docs_required <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "docs_required",
"source": "producer_test",
"external_id": "${RUN_ID}-docs_required",
"notification_datetime": "${DT_DOCS_REQ}",
"header": "Загрузите документы для миграционного учёта",
"text": "Для проверки соблюдения миграционного законодательства нужен комплект документов — загрузите до 01 августа.",
"date_expired": "${DEADLINE_DOCS}",
"details": {
"deadline": "${DEADLINE_DOCS}",
"details_header": "Документы для постановки на миграционный учёт",
"details_text": "Специалист проверит комплект в течение 1 рабочего дня после отправки. Принимаются чёткие фото или сканы в JPG, PNG, PDF. Каждый файл — до 10 МБ, не более 10 файлов за одну отправку.\n\nВсе документы должны быть действительными на дату проверки. Если какого-то документа пока нет (например, договор найма ещё не подписан), загрузите доступные — оператор подскажет порядок действий и допустимые альтернативы.",
"todo_header": "Необходимый пакет",
"todo_plan": [
{"number": 1, "text": "Паспорт: страница с фото, действующая виза или иной документ на право пребывания."},
{"number": 2, "text": "Миграционная карта с отметкой о въезде (обе стороны)."},
{"number": 3, "text": "Документ о месте пребывания: договор найма, свидетельство собственности или письмо принимающей стороны."},
{"number": 4, "text": "При трудовой деятельности — копия патента или разрешения на работу (если применимо)."},
{"number": 5, "text": "Нажмите «Отправить документы», когда все файлы приложены."}
],
"send_documents": true
}
}
JSON
if [[ -n "${CONTRACT_KEY}" && -n "${RESULTS_KEY}" ]]; then
write_payload docs_ready <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "docs_ready",
"source": "producer_test",
"external_id": "${RUN_ID}-docs_ready",
"notification_datetime": "${DT_DOCS_READY}",
"header": "Миграционные документы готовы",
"text": "Уведомление о постановке на учёт и справка о соблюдении требований закона сформированы — скачайте в деталях.",
"details": {
"details_header": "Документы по миграционному учёту",
"details_text": "Документы подготовлены на основании переданных вами данных и проверены специалистом. Сохраните копии на устройство — они могут понадобиться при проверке или продлении статуса пребывания.\n\nСсылки на скачивание действуют ограниченное время. После первого скачивания карточка скроется с главной, но останется в Центре уведомлений до нажатия «Понятно».",
"documents": [
{
"object_key": "${CONTRACT_KEY}",
"title": "${CONTRACT_TITLE}",
"mime_type": "application/pdf",
"size_bytes": ${CONTRACT_SIZE},
"checksum_sha256": "${CONTRACT_SHA256}"
},
{
"object_key": "${RESULTS_KEY}",
"title": "${RESULTS_TITLE}",
"mime_type": "application/pdf",
"size_bytes": ${RESULTS_SIZE},
"checksum_sha256": "${RESULTS_SHA256}"
}
]
}
}
JSON
fi
write_payload status_changed <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "status_changed",
"source": "producer_test",
"external_id": "${RUN_ID}-status_changed",
"notification_datetime": "${DT_STATUS}",
"header": "Статус миграционной заявки обновлён",
"text": "Заявка №МГ-8841 переведена на этап «Проверка комплектности документов».",
"details": {
"details_header": "Ход рассмотрения заявки №МГ-8841",
"details_text": "27.07.2026 в 11:42 специалист принял ваш пакет документов в работу. Сейчас выполняется проверка на соответствие требованиям миграционного законодательства РФ: сроки пребывания, адрес учёта, основания для трудовой деятельности.\n\nОжидаемое время на этом этапе: 1–2 рабочих дня. При выявлении недостающих документов вы получите отдельное уведомление с перечнем. При успешной проверке будет сформировано уведомление о постановке на учёт.",
"todo_header": "Этапы обработки",
"todo_plan": [
{"number": 1, "text": "Документы получены — выполнено 25.07.2026"},
{"number": 2, "text": "Первичная верификация — выполнено 27.07.2026"},
{"number": 3, "text": "Проверка комплектности и сроков — в работе"},
{"number": 4, "text": "Формирование уведомления / рекомендаций — ожидает"}
]
}
}
JSON
write_payload reminder <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "reminder",
"source": "producer_test",
"external_id": "${RUN_ID}-reminder",
"notification_datetime": "${DT_REMINDER}",
"header": "Напоминание: продление патента",
"text": "28 июля истекает срок действия патента — подайте заявление на продление заранее.",
"date_expired": "${DEADLINE_REMINDER}",
"details": {
"deadline": "${DEADLINE_REMINDER}",
"details_header": "Сроки продления документа на право работы",
"details_text": "Патент на работу необходимо продлевать заблаговременно — подача заявления рекомендуется не позднее чем за 10–15 рабочих дней до даты окончания действия. Просрочка означает прекращение права на трудовую деятельность и риск штрафа по ст. 18.15 КоАП РФ.\n\nДля продления потребуются: действующий патент, чеки об оплате авансовых платежей по НДФЛ, полис ДМС, сертификат о знании русского языка (если срок действия истекает), договор найма или иной документ о месте пребывания.",
"todo_header": "Чек-лист перед подачей",
"todo_plan": [
{"number": 1, "text": "Проверьте дату окончания патента в личном кабинете или на бланке документа."},
{"number": 2, "text": "Убедитесь, что авансовые платежи по НДФЛ оплачены без просрочки."},
{"number": 3, "text": "Подготовьте сканы документов — при необходимости загрузите через уведомление «Требуются документы»."},
{"number": 4, "text": "При вопросах напишите оператору — укажите регион и номер патента."}
]
}
}
JSON
write_payload news <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "news",
"source": "producer_test",
"external_id": "${RUN_ID}-news",
"notification_datetime": "${DT_NEWS}",
"header": "Изменения в правилах миграционного учёта",
"text": "С 1 августа 2026 уточнены сроки подачи уведомлений при смене адреса пребывания.",
"details": {
"details_header": "Что изменилось для иностранных граждан",
"details_text": "С 01.08.2026 при смене адреса фактического проживания в том же субъекте РФ уведомление необходимо подать в течение 3 рабочих дней (ранее — 7). При переезде в другой регион срок остаётся 7 рабочих дней с даты регистрации по новому адресу.\n\nСервис HAN напомнит о приближающихся сроках через Центр уведомлений. Рекомендуем заранее подготовить копии договора найма и отметку о регистрации — это ускорит проверку оператором.\n\nПодробности процедуры — в чате с оператором или на официальном портале МВД России."
}
}
JSON
write_payload promo_personal <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "promo_personal",
"source": "producer_test",
"external_id": "${RUN_ID}-promo_personal",
"notification_datetime": "${DT_PROMO}",
"header": "Скидка 15% на годовое сопровождение",
"text": "Персональное предложение: контроль сроков патента, учёта и уведомлений — до конца месяца.",
"price": "15725.00",
"old_price": "18500.00",
"date_expired": "${EXPIRE_PAYMENT}",
"chat_message_text": "Здравствуйте! Хочу подключить годовое сопровождение по соблюдению миграционного законодательства со скидкой 15%. Подскажите, что входит в пакет и как оформить."
}
JSON
write_payload ads_personal <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "ads_personal",
"source": "producer_test",
"external_id": "${RUN_ID}-ads_personal",
"notification_datetime": "${DT_ADS}",
"header": "Бесплатная проверка миграционного статуса",
"text": "15 минут с экспертом: оценим риски и составим чек-лист обязательных действий.",
"chat_message_text": "Здравствуйте! Хочу воспользоваться бесплатной проверкой миграционного статуса. Подскажите, как записаться и какие документы подготовить к консультации."
}
JSON
# --- отправка ---
create_notification urgent
create_notification payment_pending
create_notification docs_required
if [[ -f "${PAYLOAD_DIR}/docs_ready.json" ]]; then
create_notification docs_ready
else
echo
echo "========== docs_ready — ПРОПУЩЕН =========="
fi
create_notification status_changed
create_notification reminder
create_notification news
create_notification promo_personal
create_notification ads_personal
echo
echo "========== Готово =========="
echo "External ID prefix: ${RUN_ID}-*"
echo
echo "Если видите 401: проверьте NOTIFICATIONS_TOKEN_PRODUCER_TEST в .env и перезапустите api-backend."
echo " grep NOTIFICATIONS_TOKEN_PRODUCER_TEST .env"
echo " docker compose --env-file .env up -d --force-recreate api-backend"
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${ENV_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
./scripts/validate-env "$CONFIG_FILE" \
--runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm seed-settings
echo "Seed and mandatory-settings validation completed."
@@ -0,0 +1,591 @@
#!/usr/bin/env bash
# Первичная подготовка Ubuntu 24.04 для HAN Chat.
#
# Скрипт настраивает только VM: пользователя развертывания, базовые пакеты,
# Docker/Compose, UFW, fail2ban, DOCKER-USER, swap и каталоги проекта.
# PostgreSQL и S3 остаются внешними управляемыми сервисами. Скрипт не создает
# .env, секреты, DNS, S3-бакеты, схемы БД и TLS-сертификаты.
#
# Запуск на свежей 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.
set -Eeuo pipefail
IFS=$'\n\t'
DEPLOY_USER="${DEPLOY_USER:-deploy}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/backend}"
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}"
SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}"
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm-setup.log}"
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
}
step() {
log ""
log "==> $*"
}
die() {
log "ОШИБКА: $*"
exit 1
}
on_error() {
local exit_code=$?
log "ОШИБКА: команда завершилась с кодом ${exit_code}, строка ${BASH_LINENO[0]}"
exit "$exit_code"
}
trap on_error ERR
require_root() {
[[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Запустите скрипт через sudo"
}
validate_parameters() {
[[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || die "Некорректный DEPLOY_USER"
[[ "$DEPLOY_DIR" == /* ]] || die "DEPLOY_DIR должен быть абсолютным путем"
[[ "$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"
}
check_os() {
step "Проверка операционной системы"
[[ -r /etc/os-release ]] || die "Не найден /etc/os-release"
# shellcheck disable=SC1091
source /etc/os-release
[[ "${ID:-}" == "ubuntu" ]] || die "Поддерживается только Ubuntu"
local major="${VERSION_ID%%.*}"
((major >= 24)) || die "Требуется Ubuntu 24.04 или новее"
log "Обнаружена ${PRETTY_NAME}"
}
update_system() {
step "Обновление системы и установка пакетов"
export DEBIAN_FRONTEND=noninteractive
apt-get update
if [[ "$SKIP_APT_UPGRADE" != "true" ]]; then
apt-get dist-upgrade -y
fi
apt-get install -y \
ca-certificates \
curl \
dos2unix \
fail2ban \
git \
gnupg \
iptables \
jq \
logrotate \
netcat-openbsd \
openssl \
python3 \
python3-venv \
rsync \
unattended-upgrades \
ufw
apt-get autoremove -y
}
configure_time() {
step "Настройка времени"
timedatectl set-timezone "$TIMEZONE"
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 -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"
fi
}
configure_account_passwords() {
step "Блокировка локальных паролей привилегированных учетных записей"
if [[ "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then
log "LOCK_ACCOUNT_PASSWORDS=false: локальные пароли root и ${DEPLOY_USER} не изменены"
return
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-ключам сохранен"
}
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"
fi
}
configure_swap() {
step "Настройка swap"
if ((SWAP_SIZE_GB == 0)); then
log "Создание swap отключено"
return
fi
if swapon --show=NAME --noheadings | grep -qx '/swapfile'; then
log "Swap уже подключен"
return
fi
if [[ ! -f /swapfile ]]; then
fallocate -l "${SWAP_SIZE_GB}G" /swapfile
chmod 600 /swapfile
mkswap /swapfile
fi
swapon /swapfile
grep -q '^/swapfile ' /etc/fstab \
|| printf '/swapfile none swap sw 0 0\n' >>/etc/fstab
printf 'vm.swappiness = 10\n' >/etc/sysctl.d/99-han-chat-swappiness.conf
sysctl --system >/dev/null
}
configure_sysctl() {
step "Настройка сетевого стека"
cat >/etc/sysctl.d/99-han-chat-hardening.conf <<'EOF'
net.ipv4.ip_forward = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.tcp_fin_timeout = 30
EOF
sysctl --system >/dev/null
}
install_docker() {
step "Установка Docker Engine и Compose"
if ! command -v docker >/dev/null 2>&1; then
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
# shellcheck disable=SC1091
source /etc/os-release
printf '%s\n' \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \
>/etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
install -d -m 755 /etc/docker
cat >/etc/docker/daemon.json <<'EOF'
{
"live-restore": true,
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "5"
},
"userland-proxy": false
}
EOF
systemctl enable --now docker
systemctl restart docker
usermod -aG docker "$DEPLOY_USER"
docker compose version >/dev/null \
|| die "Docker Compose plugin не установлен"
log "$(docker --version)"
log "$(docker compose version)"
}
configure_ufw() {
step "Настройка UFW"
if [[ "$RESET_UFW" == "true" ]]; then
ufw --force reset
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 80/tcp comment 'HAN Chat HTTP'
ufw allow 443/tcp comment 'HAN Chat HTTPS'
ufw logging medium
ufw --force enable
}
configure_fail2ban() {
step "Настройка fail2ban для SSH"
cat >/etc/fail2ban/jail.d/han-chat.local <<EOF
[DEFAULT]
bantime = 2h
findtime = 10m
maxretry = 5
backend = systemd
banaction = ufw
[sshd]
enabled = true
port = ${SSH_PORT}
maxretry = 3
EOF
systemctl enable --now fail2ban
systemctl restart fail2ban
}
configure_unattended_upgrades() {
step "Автоматические обновления безопасности"
cat >/etc/apt/apt.conf.d/51han-chat-unattended <<'EOF'
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
EOF
dpkg-reconfigure -f noninteractive unattended-upgrades
systemctl enable --now unattended-upgrades
}
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'
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck disable=SC1091
source /etc/default/han-chat-docker-firewall
external_if="${EXTERNAL_IF:-}"
if [[ -z "$external_if" ]]; then
external_if="$(ip -4 route show default | awk '{print $5; exit}')"
fi
[[ -n "$external_if" ]] || {
echo "Не удалось определить внешний интерфейс; задайте EXTERNAL_IF" >&2
exit 1
}
iptables -N HAN-CHAT-DOCKER 2>/dev/null || true
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
# Блокируется только новый входящий трафик с внешнего интерфейса в Docker bridge.
# Исходящий и межконтейнерный трафик этой цепочкой не затрагивается.
iptables -A HAN-CHAT-DOCKER -i "$external_if" -o docker+ -j DROP
iptables -A HAN-CHAT-DOCKER -i "$external_if" -o br+ -j DROP
iptables -A HAN-CHAT-DOCKER -j RETURN
while iptables -C DOCKER-USER -j HAN-CHAT-DOCKER 2>/dev/null; do
iptables -D DOCKER-USER -j HAN-CHAT-DOCKER
done
iptables -I DOCKER-USER 1 -j HAN-CHAT-DOCKER
FIREWALL
chmod 750 /usr/local/sbin/han-chat-docker-firewall
cat >/etc/systemd/system/han-chat-docker-firewall.service <<'EOF'
[Unit]
Description=HAN Chat firewall for Docker published ports
After=docker.service network-online.target
Wants=docker.service network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/han-chat-docker-firewall
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
install -d -m 755 /etc/systemd/system/docker.service.d
cat >/etc/systemd/system/docker.service.d/han-chat-firewall.conf <<'EOF'
[Service]
ExecStartPost=-/usr/local/sbin/han-chat-docker-firewall
EOF
systemctl daemon-reload
systemctl enable --now han-chat-docker-firewall.service
}
configure_ssh() {
step "Настройка SSH"
[[ -s /root/.ssh/authorized_keys || -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|| die "Нельзя отключить парольный SSH-вход: не найден ни один authorized_keys"
cat >/etc/ssh/sshd_config.d/00-han-chat.conf <<EOF
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 120
ClientAliveCountMax 2
Port ${SSH_PORT}
EOF
if [[ "$HARDEN_SSH" == "true" ]]; then
cat >>/etc/ssh/sshd_config.d/00-han-chat.conf <<'EOF'
PermitRootLogin no
AllowTcpForwarding no
EOF
log "Расширенный SSH hardening включен: root-вход и TCP forwarding запрещены"
else
log "Базовый SSH hardening включен; root-вход и TCP forwarding не изменены"
fi
rm -f /etc/ssh/sshd_config.d/99-han-chat.conf
sshd -t || die "Проверка конфигурации sshd не пройдена"
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_secret_loader_if_possible() {
step "Загрузчик секретов"
local source_dir="${DEPLOY_DIR}/deployment/secrets"
if [[ ! -f "${source_dir}/secrets_loader.py" || ! -f "${source_dir}/han-secrets" ]]; then
log "Проект еще не скопирован: загрузчик секретов будет установлен при повторном запуске"
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
install -m 0750 -o root -g root \
"${source_dir}/secrets_loader.py" \
/usr/local/lib/han-secrets/secrets_loader.py
install -m 0750 -o root -g root \
"${source_dir}/han-secrets" \
/usr/local/lib/han-secrets/han-secrets
install -m 0750 -o root -g root \
"${source_dir}/han-compose" \
/usr/local/bin/han-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 \
"${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
}
verify() {
step "Проверка результата"
local failed=0
systemctl is-active --quiet docker || { log "FAIL: Docker не активен"; failed=1; }
systemctl is-active --quiet fail2ban || { log "FAIL: fail2ban не активен"; failed=1; }
ufw status | grep -q 'Status: active' || { log "FAIL: UFW не активен"; failed=1; }
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; }
((failed == 0)) || die "Базовая проверка VM не пройдена"
log "Базовая проверка VM пройдена"
}
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}
Следующие действия:
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 начнет действовать после нового входа в систему.
EOF
}
main() {
require_root
install -d -m 755 "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
chmod 600 "$LOG_FILE"
validate_parameters
check_os
update_system
configure_time
create_deploy_user
configure_account_passwords
configure_layout
configure_swap
configure_sysctl
install_docker
configure_ufw
configure_fail2ban
configure_unattended_upgrades
configure_docker_firewall
configure_ssh
configure_application_security
install_secret_loader_if_possible
install_ssl_timer_if_possible
verify
summary
}
main "$@"
@@ -0,0 +1,71 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${CONFIG_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
if [ -n "${HAN_RUNTIME_SECRET_MANIFEST:-}" ]; then
./scripts/validate-env "$CONFIG_FILE" --runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
else
./scripts/validate-env "$CONFIG_FILE" --runtime-env
fi
env_value() {
python3 - "$CONFIG_FILE" "$1" <<'PY'
import sys
from pathlib import Path
path, wanted = 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)
break
else:
raise SystemExit(f"missing environment variable: {wanted}")
PY
}
PUBLIC_HOST=$(env_value PUBLIC_HOST)
PUBLIC_WEB_URL=$(env_value PUBLIC_WEB_URL)
KEYCLOAK_REALM=$(env_value KEYCLOAK_REALM)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
http_code=$(curl -sS -o /dev/null -w '%{http_code}' "http://${PUBLIC_HOST}/")
[ "$http_code" = "308" ] || { echo "Expected HTTP 308, got $http_code" >&2; exit 1; }
curl -fsS "${PUBLIC_WEB_URL}/api/v1/public/app-config" -o "$tmp/app-config.json"
curl -fsS "${PUBLIC_WEB_URL}/api/v1/public/content" -o "$tmp/content.json"
curl -fsS "${PUBLIC_WEB_URL}/auth/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration" -o "$tmp/oidc.json"
internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/safety/v1/messages/check")
[ "$internal_code" = "404" ] || { echo "Public /internal returned $internal_code, expected 404" >&2; exit 1; }
sms_internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/sms/v1/messages/00000000-0000-0000-0000-000000000000")
[ "$sms_internal_code" = "404" ] || { echo "Public SMS internal API returned $sms_internal_code, expected 404" >&2; exit 1; }
sms_callback_code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' --data '[]' \
"${PUBLIC_WEB_URL}/callbacks/idgtl/sms")
[ "$sms_callback_code" = "403" ] || { echo "SMS callback without provider IP returned $sms_callback_code, expected 403" >&2; exit 1; }
headers=$(curl -fsSI "${PUBLIC_WEB_URL}/")
printf '%s' "$headers" | grep -qi '^x-content-type-options: nosniff'
printf '%s' "$headers" | grep -qi '^x-request-id:'
printf '%s' "$headers" | grep -qi '^content-security-policy:'
openssl s_client -connect "${PUBLIC_HOST}:443" -servername "$PUBLIC_HOST" </dev/null 2>/dev/null \
| openssl x509 -noout -checkend 604800
echo "Public edge smoke passed."
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${CONFIG_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
lock=/tmp/han-chat-cert-renew.lock
exec 9>"$lock"
flock -n 9 || { echo '{"event":"tls.renew.skipped","reason":"lock_busy"}'; exit 0; }
compose() {
docker compose --env-file "$CONFIG_FILE" "$@"
}
nginx_container="$(compose ps --status running --quiet nginx)"
if [ -z "$nginx_container" ]; then
echo '{"event":"tls.renew.failed","reason":"nginx_not_running"}' >&2
exit 1
fi
compose --profile certbot run --rm certbot renew \
--webroot -w /var/www/certbot --quiet
compose exec -T nginx nginx -t -c /tmp/nginx.conf
# Сигнал отправляется PID 1 контейнера. Нельзя использовать `nginx -s reload`:
# он ищет дефолтный /var/run/nginx.pid, тогда как рабочий PID — /tmp/nginx.pid.
compose kill --signal HUP nginx
compose ps --status running --quiet nginx | awk 'NF {found=1} END {exit !found}'
echo "{\"event\":\"tls.renew.completed\",\"timestamp\":\"$(date -u +%FT%TZ)\"}"
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd "$(dirname "$0")/../.."
CONFIG_FILE="${CONFIG_FILE:-.env}"
SECRETS_LAUNCHER="${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}"
if [[ "${HAN_SECRETS_ACTIVE:-0}" != 1 ]]; then
[[ -x "$SECRETS_LAUNCHER" ]] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
compose() { docker compose --env-file "$CONFIG_FILE" "$@"; }
NETWORK="${OBSERVABILITY_NETWORK:-han-chat-observability}"
COLLECTOR_SERVICE="${COLLECTOR_SERVICE:-otel-collector}"
errors=0
ok() { printf 'OK %s\n' "$*"; }
fail() { printf 'FAIL %s\n' "$*" >&2; errors=$((errors + 1)); }
collector_id="$(compose ps -q "$COLLECTOR_SERVICE" 2>/dev/null || true)"
if [[ -n "$collector_id" ]] &&
[[ "$(docker inspect --format '{{.State.Status}}' "$collector_id")" == running ]]; then
ok "Collector service is running"
else
fail "Collector service '$COLLECTOR_SERVICE' is not running"
fi
for service in sms-service sms-worker; do
if compose exec -T "$service" python - <<'PY' >/dev/null 2>&1
import os
import urllib.request
port = os.environ.get("SMS_METRICS_PORT", "9464") if "worker" in os.environ.get("OTEL_SERVICE_NAME", "") else "8080"
urllib.request.urlopen(f"http://127.0.0.1:{port}/metrics", timeout=3).read(1024)
PY
then
ok "${service} metrics endpoint"
else
fail "${service} metrics endpoint"
fi
done
docker run --rm --network "$NETWORK" \
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
traces --otlp-endpoint otel-collector:4317 --otlp-insecure \
--service han-chat-e2e-canary --traces 100 --rate 20 >/dev/null \
&& ok "100 canary traces submitted" \
|| fail "telemetrygen failed"
bad_logs="$(
compose logs --since=10m "$COLLECTOR_SERVICE" 2>&1 |
grep -Ei 'queue is full|connection refused|tls:|Unauthenticated|Permanent error' || true
)"
if [[ -z "$bad_logs" ]]; then
ok "No exporter/queue errors in last 10 minutes"
else
fail "Collector reports exporter/queue errors"
printf '%s\n' "$bad_logs" >&2
fi
if ((errors)); then
printf 'Observability verification failed: %d check(s)\n' "$errors" >&2
exit 1
fi
cat <<'EOF'
Локальный канал исправен. В SigNoz проверьте за последние 15 минут:
service.name = han-chat-e2e-canary
service.namespace = han-chat
Затем выполните synthetic API request и проверьте общий trace между
api-backend и dependency spans, service.version и deployment.environment.
EOF
@@ -0,0 +1,206 @@
# Selectel Secrets Manager для HAN Chat
## Модель
`han-secrets` читает только `SECRETS_SOURCE=selectel|file` из обычного `.env`,
выбирает соответствующую root-only JSON-карту и вызывает `secrets_loader.py`.
Загрузчик получает project-scoped IAM token, читает объявленные секреты и
создаёт в `/run/han-chat/secrets`:
- отдельные файлы с каноническими именами для Compose secrets;
- узкие service dotenv-файлы для диагностики состава без вывода значений;
- `manifest` вида `NAME=/absolute/path`, используемый валидатором.
Каталог `/run` находится в tmpfs и имеет режим `0700`. Канонические файлы имеют
`0444`: локальные пользователи не могут пройти через root-only каталог, а
не-root UID контейнера может прочитать только явно смонтированный Compose
secret. Значения не передаются через Docker Config.Env, argv, общий `.env` или
логи. После полного root/docker-компромисса runtime-значения извлекаемы — это
ограничение модели, а не гарантия Secret Manager.
Сбой Selectel никогда автоматически не включает file fallback. Уже работающие
контейнеры продолжают использовать текущие значения; новый sync завершается
fail-closed.
## 1. Ресурсы Selectel
1. Создайте отдельный проект `han-chat-secrets-prod`.
2. Создайте сервисного пользователя `han-chat-secrets-reader`.
3. Назначьте ему `member` только в этом проекте. Не выдавайте account scope,
`iam.admin` и доступ к другим production-ресурсам. Если Selectel добавит
отдельную read-only роль Secrets Manager, замените `member` на неё.
4. Ограничьте обращения к `api.selectel.ru` исходящим IP ВМ, если функция
доступна в аккаунте.
5. Включите экспорт audit logs. Контролируйте события `secrets.secret*` и
`secrets.secret_version*`; alert на delete, смену current version вне окна,
массовые чтения и обращения не от штатного пользователя/IP.
6. Выполните canary и убедитесь, что provider audit содержит metadata операции,
но не value, Base64 payload, IAM token или response body.
Secrets Manager принимает project-scoped IAM token в `X-Auth-Token`. Token
живёт до 24 часов, но загрузчик использует его только в памяти одного запуска.
TLS и redirect policy отключать нельзя.
## 2. Каталог секретов
Скопируйте `config.example.json` в
`/etc/han/secrets/production-like.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-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-message-safety-token`, `han-chat-prod-bitrix-internal-token`,
`han-chat-prod-bitrix-forward-token`, `han-chat-prod-bitrix-sync-token`,
`han-chat-prod-keycloak-settings-token`, `han-chat-prod-sms-service-token`;
- Keycloak bootstrap password, OTP HMAC и mock code только для среды, где mock
действительно включён;
- Bitrix client secret, application token и token encryption key;
- пары S3 app access/secret key;
- i-Digital API key и callback username/password.
Одинаковые пары env (`BITRIX_LOCAL_APP_INTERNAL_TOKEN` /
`BITRIX_INTERNAL_API_TOKEN`, `BITRIX_API_FORWARD_TOKEN` /
`BITRIX_API_INBOX_TOKEN`, `SMS_SERVICE_TOKEN` /
`KEYCLOAK_SMS_SERVICE_TOKEN`) должны ссылаться на один `remote`.
`literal: ""` разрешён только для заведомо пустого optional-параметра, например
OTLP auth при self-hosted SigNoz или выключенного CAPTCHA server key. Секреты
не записывайте в JSON. Не заводите planned/unused Message Safety DB, quarantine
S3 и Bitrix sync credentials до появления потребляющего кода.
Загружайте значения в Selectel через скрытый prompt/stdin. Не передавайте value
позиционным аргументом CLI и не включайте `set -x`/`curl -v`.
## 3. Установка на ВМ
Повторный запуск `deployment/scripts/setup-vm.sh` после копирования проекта
устанавливает loader, launcher, systemd template и этот runbook. Вручную:
```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
```
Обычный `/opt/han-chat/backend/.env` содержит только несекретные параметры.
Штатный режим:
```dotenv
SECRETS_SOURCE=selectel
APP_ENV=production-like
```
## 4. Bootstrap credential
Не храните пароль service user в `.env` или JSON. На целевой ВМ:
```sh
sudo systemd-creds encrypt --name=selectel-service-user-password - \
/etc/han/credentials/production.selectel-password.cred
sudo chmod 0600 /etc/han/credentials/production.selectel-password.cred
```
Введите пароль через интерактивный stdin. Unit передаёт расшифрованный файл
через приватный `$CREDENTIALS_DIRECTORY`. Root всё равно может его извлечь;
после инцидента credential и все доступные ему секреты необходимо ротировать.
## 5. Проверка и запуск
Все команды, которым нужны Compose secrets, запускайте от root через wrapper:
```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 \
--runtime-manifest /run/han-chat/secrets/manifest
sudo deployment/secrets/han-compose config --quiet
sudo deployment/secrets/han-compose up -d --wait
```
Selectel sync запускается именно unit-файлом: только он предоставляет
расшифрованный bootstrap credential через `$CREDENTIALS_DIRECTORY`.
`han-compose` и ops-скрипты используют уже синхронизированный manifest и
отказываются работать, если `SECRETS_SOURCE`/loader config не совпадают с
runtime state. После смены source, provider version или JSON-карты сначала
выполняйте `systemctl restart han-secrets@production.service`.
Не используйте `docker compose config` без `--quiet`, `docker inspect` для
поиска конфигурации, `env`, `strace`, core dump или debug HTTP proxy. Проверка
приёмки должна подтвердить отсутствие 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
daemon/VM, иначе fail-closed HAN остановит несвязанные системы.
## 6. Явный file fallback
Подготовьте отдельную карту
`/etc/han/secrets/production-like.file.json`: скопируйте Selectel-карту,
установите `"mode": "file"`, удалите `selectel` и `http`, добавьте:
```json
"file": {
"path": "/etc/han/break-glass/secrets.env",
"max_bytes": 1048576
}
```
`secrets` map остаётся тем же. Для записей с `literal: ""` строка в fallback
не нужна; остальные канонические ключи обязательны. Fallback parser не исполняет
shell: запрещены `export`, substitutions, multiline, неизвестные и дублирующиеся
ключи. Файл — `root:root 0600`.
При инциденте доставьте recovery-файл из защищённой офлайн-копии и только затем
явно измените `.env`:
```dotenv
SECRETS_SOURCE=file
```
Перезапустите `han-secrets@production.service`, затем выполните
validate/recreate через wrappers. После восстановления Selectel верните
`SECRETS_SOURCE=selectel`, снова перезапустите unit, повторите проверки и
удалите recovery-файл.
Не храните его постоянно на ВМ: это вернуло бы исходный риск монолитного `.env`.
## 7. Ротация и rollback
1. Добавьте новую версию секрета, не меняя имя.
2. Для canary при необходимости временно pin числовой `version` в JSON-карте.
3. Выполните sync, validation и smoke без вывода конфигурации.
4. Сделайте версию current, удалите pin, снова sync и пересоздайте только
потребителей.
5. Для rollback активируйте предыдущую provider version; не храните snapshot
старого `.env`.
Selectel не позволяет удалить отдельную версию — только секрет целиком. Старые
значения должны быть отозваны в PostgreSQL/S3/Bitrix/i-Digital после окна
rollback. Все значения из прежнего `.env` считайте раскрытыми и ротируйте после
перехода.
@@ -0,0 +1,190 @@
{
"version": 1,
"mode": "selectel",
"runtime_dir": "/run/han-chat/secrets",
"http": {
"timeout_seconds": 10,
"retries": 3,
"max_response_bytes": 1048576
},
"selectel": {
"account_id": "123456",
"username": "han-secrets-reader",
"project_name": "han-production",
"region": "ru-9",
"interface": "public",
"password_file": "selectel-service-user-password"
},
"secrets": {
"DATABASE_URL": {
"remote": "DATABASE_URL",
"consumers": ["api-backend", "api-migrate"],
"max_bytes": 4096
},
"BITRIX_DATABASE_URL": {
"remote": "BITRIX_DATABASE_URL",
"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"],
"max_bytes": 4096
},
"KEYCLOAK_DB_PASSWORD": {
"remote": "KEYCLOAK_DB_PASSWORD",
"consumers": ["keycloak"],
"max_bytes": 1024
},
"KEYCLOAK_ADMIN_PASSWORD": {
"remote": "KEYCLOAK_ADMIN_PASSWORD",
"consumers": ["keycloak"],
"max_bytes": 1024
},
"CURSOR_HMAC_SECRET": {
"remote": "CURSOR_HMAC_SECRET",
"consumers": ["api-backend"],
"max_bytes": 1024
},
"KEYCLOAK_OTP_HMAC_KEY": {
"remote": "KEYCLOAK_OTP_HMAC_KEY",
"consumers": ["keycloak"],
"max_bytes": 1024
},
"KEYCLOAK_OTP_MOCK_CODE": {
"remote": "KEYCLOAK_OTP_MOCK_CODE",
"consumers": ["keycloak"],
"max_bytes": 1024
},
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY": {
"remote": "KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY",
"consumers": ["keycloak"],
"max_bytes": 1024
},
"BITRIX_TOKEN_ENCRYPTION_KEY": {
"remote": "BITRIX_TOKEN_ENCRYPTION_KEY",
"consumers": ["api-backend", "bitrix-local-app", "bitrix-sync"],
"max_bytes": 1024
},
"REDIS_API_PASSWORD": {
"remote": "REDIS_API_PASSWORD",
"consumers": ["redis"],
"max_bytes": 1024
},
"REDIS_URL": {
"remote": "REDIS_URL",
"consumers": ["api-backend", "delivery-worker", "cleanup-worker"],
"max_bytes": 4096
},
"REDIS_REALTIME_URL": {
"remote": "REDIS_REALTIME_URL",
"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"],
"max_bytes": 1024
},
"MESSAGE_SAFETY_SERVICE_TOKEN": {
"remote": "MESSAGE_SAFETY_SERVICE_TOKEN",
"consumers": ["api-backend", "message-safety"],
"max_bytes": 1024
},
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": {
"remote": "BITRIX_LOCAL_APP_INTERNAL_TOKEN",
"consumers": ["api-backend", "bitrix-local-app"],
"max_bytes": 1024
},
"BITRIX_INTERNAL_API_TOKEN": {
"remote": "BITRIX_LOCAL_APP_INTERNAL_TOKEN",
"consumers": ["api-backend", "bitrix-local-app"],
"max_bytes": 1024
},
"BITRIX_API_FORWARD_TOKEN": {
"remote": "BITRIX_API_FORWARD_TOKEN",
"consumers": ["api-backend", "bitrix-local-app"],
"max_bytes": 1024
},
"BITRIX_API_INBOX_TOKEN": {
"remote": "BITRIX_API_FORWARD_TOKEN",
"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"],
"max_bytes": 1024
},
"SMS_SERVICE_TOKEN": {
"remote": "SMS_SERVICE_TOKEN",
"consumers": ["sms-service", "sms-worker", "keycloak"],
"max_bytes": 1024
},
"KEYCLOAK_SMS_SERVICE_TOKEN": {
"remote": "SMS_SERVICE_TOKEN",
"consumers": ["sms-service", "sms-worker", "keycloak"],
"max_bytes": 1024
},
"IDGTL_SMS_API_KEY": {
"remote": "IDGTL_SMS_API_KEY",
"consumers": ["sms-service", "sms-worker"],
"max_bytes": 1024
},
"IDGTL_SMS_CALLBACK_USERNAME": {
"remote": "IDGTL_SMS_CALLBACK_USERNAME",
"consumers": ["sms-service", "sms-worker"],
"max_bytes": 1024
},
"IDGTL_SMS_CALLBACK_PASSWORD": {
"remote": "IDGTL_SMS_CALLBACK_PASSWORD",
"consumers": ["sms-service", "sms-worker"],
"max_bytes": 1024
},
"BITRIX_CLIENT_SECRET": {
"remote": "BITRIX_CLIENT_SECRET",
"consumers": ["bitrix-local-app"],
"max_bytes": 1024
},
"BITRIX_APPLICATION_TOKEN": {
"remote": "BITRIX_APPLICATION_TOKEN",
"consumers": ["bitrix-local-app"],
"max_bytes": 1024
},
"SELECTEL_S3_SECRET_KEY": {
"remote": "SELECTEL_S3_SECRET_KEY",
"consumers": ["api-backend"],
"max_bytes": 1024
},
"SELECTEL_S3_ACCESS_KEY": {
"remote": "SELECTEL_S3_ACCESS_KEY",
"consumers": ["api-backend"],
"max_bytes": 1024
},
"OTEL_REMOTE_AUTH_HEADER": {
"literal": "",
"consumers": ["otel-collector"],
"max_bytes": 4096
}
}
}
@@ -0,0 +1,6 @@
[Unit]
# Enable only on a dedicated HAN Chat Docker host after the Selectel canary and
# file fallback have both passed. A failed secret sync intentionally blocks
# Docker startup so containers cannot race an empty /run directory.
Requires=han-secrets@production.service
After=han-secrets@production.service
@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
DEPLOY_DIR=${HAN_DEPLOY_DIR:-/opt/han-chat/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}
LAUNCHER=${HAN_SECRETS_LAUNCHER:-/usr/local/lib/han-secrets/han-secrets}
[ -x "$LAUNCHER" ] || LAUNCHER=deployment/secrets/han-secrets
exec "$LAUNCHER" run --config "$CONFIG_FILE" -- \
docker compose --env-file "$CONFIG_FILE" "$@"
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Synchronize runtime secrets and execute a command without exporting their values."""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from secrets_loader import LoaderError, load_json, run
def load_public_config(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError as exc:
raise LoaderError(
f"cannot read non-secret config: {exc.strerror or exc.__class__.__name__}"
) from None
for number, raw in enumerate(lines, 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise LoaderError(f"non-secret config has invalid syntax at line {number}")
key, value = line.split("=", 1)
key = key.strip()
if not key or key in values:
raise LoaderError(f"non-secret config has an invalid key at line {number}")
values[key] = value.strip()
return values
def loader_config_path(
public: dict[str, str],
explicit: Path | None,
environ: dict[str, str],
) -> tuple[str, Path]:
source = public.get("SECRETS_SOURCE")
if source not in {"selectel", "file"}:
raise LoaderError("SECRETS_SOURCE must explicitly be selectel or file")
if explicit is not None:
return source, explicit
override = environ.get(f"HAN_SECRETS_{source.upper()}_CONFIG")
if override:
return source, Path(override)
environment = public.get("APP_ENV", "production")
return source, Path(f"/etc/han/secrets/{environment}.{source}.json")
def prepare_environment(
config_path: Path,
source: str,
environ: dict[str, str],
*,
synchronize: bool,
) -> dict[str, str]:
document = load_json(config_path)
if document.get("mode") != source:
raise LoaderError("selected loader configuration mode does not match SECRETS_SOURCE")
runtime = document.get("runtime_dir")
if not isinstance(runtime, str) or not Path(runtime).is_absolute():
raise LoaderError("loader configuration has an invalid runtime_dir")
runtime_dir = Path(runtime)
manifest = runtime_dir / "manifest"
state_path = runtime_dir / "state.json"
consumers: list[str] = []
if synchronize or (source == "file" and not state_path.is_file()):
consumers = run(config_path, environ=environ)
state = {
"version": 1,
"source": source,
"loader_config": str(config_path.resolve()),
}
descriptor, temporary = tempfile.mkstemp(
prefix=".state.", suffix=".tmp", dir=runtime_dir
)
temporary_path = Path(temporary)
try:
os.chmod(temporary_path, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
descriptor = -1
json.dump(state, stream, separators=(",", ":"))
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_path, state_path)
except BaseException:
if descriptor >= 0:
os.close(descriptor)
temporary_path.unlink(missing_ok=True)
raise
elif not state_path.is_file():
raise LoaderError(
"runtime secrets are not synchronized; restart han-secrets systemd unit"
)
else:
state = load_json(state_path)
if (
state.get("version") != 1
or state.get("source") != source
or state.get("loader_config") != str(config_path.resolve())
):
raise LoaderError(
"runtime secret state does not match selected source/config; synchronize first"
)
if not manifest.is_file():
raise LoaderError("runtime secret manifest was not materialized")
manifest_entries: dict[str, str] = {}
for line in manifest.read_text(encoding="utf-8").splitlines():
key, value_path = line.split("=", 1)
manifest_entries[key] = value_path
specs = document.get("secrets")
if not isinstance(specs, dict) or set(manifest_entries) != set(specs):
raise LoaderError("runtime secret manifest does not match loader configuration")
child = dict(environ)
child["HAN_SECRETS_ACTIVE"] = "1"
child["HAN_RUNTIME_SECRET_DIR"] = str(runtime_dir)
child["HAN_RUNTIME_SECRET_MANIFEST"] = str(manifest)
for key, value_path in manifest_entries.items():
if not Path(value_path).is_file():
raise LoaderError("runtime secret manifest references a missing file")
child[f"{key}_FILE"] = value_path
if synchronize or consumers:
print(
f"han-secrets: synchronized {len(consumers)} service scope(s) from {source}",
file=sys.stderr,
)
return child
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("sync", "run"))
parser.add_argument("--config", type=Path, default=Path(".env"))
parser.add_argument("--loader-config", type=Path)
arguments, command = parser.parse_known_args(argv)
if command and command[0] == "--":
command.pop(0)
if arguments.action == "run" and not command:
parser.error("run requires a command after --")
if arguments.action == "sync" and command:
parser.error("sync does not accept a command")
environment = dict(os.environ)
try:
public = load_public_config(arguments.config)
source, loader_config = loader_config_path(
public, arguments.loader_config, environment
)
child = prepare_environment(
loader_config,
source,
environment,
synchronize=arguments.action == "sync",
)
except (LoaderError, OSError, ValueError, json.JSONDecodeError) as exc:
message = str(exc) if isinstance(exc, LoaderError) else exc.__class__.__name__
print(f"han-secrets: {message}", file=sys.stderr)
return 1
if arguments.action == "sync":
return 0
if os.name == "nt":
return subprocess.call(command, env=child)
os.execvpe(command[0], command, child)
return 127
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,42 @@
[Unit]
Description=Materialize HAN 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
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
RuntimeDirectory=han-chat/secrets
RuntimeDirectoryMode=0700
ExecStart=/usr/bin/python3 /usr/local/lib/han-secrets/han-secrets sync --config /opt/han-chat/backend/.env
LoadCredentialEncrypted=selectel-service-user-password:/etc/han/credentials/%i.selectel-password.cred
RemainAfterExit=yes
StandardOutput=null
StandardError=journal
SyslogIdentifier=han-secrets-%i
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
LimitCORE=0
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
CapabilityBoundingSet=
AmbientCapabilities=
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,682 @@
#!/usr/bin/env python3
"""Materialize narrowly scoped service dotenv files from Selectel Secrets Manager."""
from __future__ import annotations
import argparse
import base64
import binascii
import json
import os
import random
import re
import ssl
import stat
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Mapping, NoReturn
DEFAULT_IDENTITY_URL = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens"
MAX_CONFIG_BYTES = 1_048_576
MAX_HTTP_BYTES = 1_048_576
MAX_SECRET_BYTES = 65_536
RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
ENV_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
SERVICE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$")
DOTENV_LINE_RE = re.compile(r"^([A-Z][A-Z0-9_]*)=(.*)$")
class LoaderError(Exception):
"""An expected, already-redacted loader failure."""
def fail(message: str) -> NoReturn:
raise LoaderError(message)
def _object(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
fail(f"{label} must be an object")
return value
def _only_keys(value: Mapping[str, Any], allowed: set[str], label: str) -> None:
unknown = sorted(set(value) - allowed)
if unknown:
fail(f"{label} contains unsupported fields: {', '.join(unknown)}")
def _required_string(value: Mapping[str, Any], key: str, label: str) -> str:
item = value.get(key)
if not isinstance(item, str) or not item:
fail(f"{label}.{key} must be a non-empty string")
return item
def _bounded_int(value: Any, label: str, minimum: int, maximum: int) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
fail(f"{label} must be an integer from {minimum} through {maximum}")
return value
def read_limited(path: Path, limit: int, label: str) -> bytes:
try:
with path.open("rb") as stream:
data = stream.read(limit + 1)
except OSError as exc:
fail(f"cannot read {label}: {exc.strerror or exc.__class__.__name__}")
if len(data) > limit:
fail(f"{label} exceeds {limit} bytes")
return data
def require_private_regular_file(path: Path, label: str) -> None:
try:
metadata = path.lstat()
except OSError as exc:
fail(f"cannot inspect {label}: {exc.strerror or exc.__class__.__name__}")
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
fail(f"{label} must be a regular file and not a symlink")
if os.name != "nt" and stat.S_IMODE(metadata.st_mode) & 0o077:
fail(f"{label} must not be accessible by group or other users")
def load_json(path: Path) -> dict[str, Any]:
raw = read_limited(path, MAX_CONFIG_BYTES, "configuration")
try:
document = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
fail("configuration is not valid UTF-8 JSON")
return _object(document, "configuration")
def credential_value(selectel: Mapping[str, Any], environ: Mapping[str, str]) -> str:
methods = sum(key in selectel for key in ("password_file", "password_env"))
if methods != 1:
fail("selectel must set exactly one of password_file or password_env")
if "password_env" in selectel:
variable = _required_string(selectel, "password_env", "selectel")
if not ENV_NAME_RE.fullmatch(variable):
fail("selectel.password_env is not a valid environment variable name")
value = environ.get(variable)
if value is None or not value:
fail(f"credential environment variable {variable} is not set")
return value
configured = Path(_required_string(selectel, "password_file", "selectel"))
if configured.is_absolute():
path = configured
else:
directory = environ.get("CREDENTIALS_DIRECTORY")
if not directory:
fail("relative password_file requires CREDENTIALS_DIRECTORY")
path = Path(directory) / configured
require_private_regular_file(path, "credential")
raw = read_limited(path, 16_384, "credential")
try:
value = raw.decode("utf-8")
except UnicodeDecodeError:
fail("credential is not valid UTF-8")
value = value.removesuffix("\n").removesuffix("\r")
if not value or "\n" in value or "\r" in value or "\x00" in value:
fail("credential must contain exactly one non-empty text line")
return value
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> None:
return None
@dataclass(frozen=True)
class HTTPResult:
status: int
headers: Mapping[str, str]
body: bytes
class HTTPClient:
def __init__(
self,
*,
timeout: float,
retries: int,
max_response_bytes: int,
cafile: str | None = None,
opener: Any | None = None,
sleeper: Callable[[float], None] = time.sleep,
jitter: Callable[[], float] = random.random,
) -> None:
self.timeout = timeout
self.retries = retries
self.max_response_bytes = max_response_bytes
self.sleeper = sleeper
self.jitter = jitter
if opener is None:
try:
context = ssl.create_default_context(cafile=cafile)
except (OSError, ssl.SSLError) as exc:
fail(f"cannot initialize TLS trust store: {exc.__class__.__name__}")
self.opener = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=context), NoRedirect()
)
else:
self.opener = opener
def request(
self,
method: str,
url: str,
*,
headers: Mapping[str, str] | None = None,
body: bytes | None = None,
expected: frozenset[int],
) -> HTTPResult:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
fail("provider endpoint must be an HTTPS URL without embedded credentials")
request = urllib.request.Request(
url, data=body, headers=dict(headers or {}), method=method
)
for attempt in range(self.retries + 1):
try:
with self.opener.open(request, timeout=self.timeout) as response:
status = int(response.status)
content_length = response.headers.get("Content-Length")
if content_length:
try:
if int(content_length) > self.max_response_bytes:
fail("provider response exceeds configured limit")
except ValueError:
fail("provider returned an invalid Content-Length")
response_body = response.read(self.max_response_bytes + 1)
if len(response_body) > self.max_response_bytes:
fail("provider response exceeds configured limit")
if status not in expected:
fail(f"provider request failed with HTTP {status}")
return HTTPResult(status, response.headers, response_body)
except urllib.error.HTTPError as exc:
status = int(exc.code)
if status not in RETRYABLE_STATUS or attempt >= self.retries:
fail(f"provider request failed with HTTP {status}")
except (urllib.error.URLError, TimeoutError, OSError):
if attempt >= self.retries:
fail("provider request failed after retries")
delay = min(8.0, 0.25 * (2**attempt)) * (0.5 + self.jitter())
self.sleeper(delay)
fail("provider request failed")
def parse_json_response(result: HTTPResult, label: str) -> dict[str, Any]:
try:
return _object(json.loads(result.body.decode("utf-8")), label)
except (UnicodeDecodeError, json.JSONDecodeError):
fail(f"{label} is not valid JSON")
def project_token_and_catalog(
client: HTTPClient, selectel: Mapping[str, Any], password: str
) -> tuple[str, list[Any]]:
identity_url = selectel.get("identity_url", DEFAULT_IDENTITY_URL)
if not isinstance(identity_url, str):
fail("selectel.identity_url must be a string")
account_id = _required_string(selectel, "account_id", "selectel")
username = _required_string(selectel, "username", "selectel")
project_name = _required_string(selectel, "project_name", "selectel")
payload = {
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": username,
"domain": {"name": account_id},
"password": password,
}
},
},
"scope": {
"project": {
"name": project_name,
"domain": {"name": account_id},
}
},
}
}
result = client.request(
"POST",
identity_url,
headers={"Content-Type": "application/json", "Accept": "application/json"},
body=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
expected=frozenset({201}),
)
token = result.headers.get("X-Subject-Token")
if not isinstance(token, str) or not token:
fail("identity response omitted X-Subject-Token")
document = parse_json_response(result, "identity response")
token_data = document.get("token")
if not isinstance(token_data, dict):
fail("identity response omitted token metadata")
project = token_data.get("project")
if not isinstance(project, dict) or not project.get("id"):
fail("identity token is not project-scoped")
catalog = token_data.get("catalog")
if not isinstance(catalog, list):
fail("identity response omitted service catalog")
return token, catalog
def secrets_endpoint(catalog: list[Any], region: str, interface: str) -> str:
matches: list[str] = []
for service in catalog:
if not isinstance(service, dict) or service.get("type") != "secrets-manager":
continue
endpoints = service.get("endpoints")
if not isinstance(endpoints, list):
continue
for endpoint in endpoints:
if (
isinstance(endpoint, dict)
and endpoint.get("region") == region
and endpoint.get("interface") == interface
and isinstance(endpoint.get("url"), str)
):
matches.append(endpoint["url"].rstrip("/"))
if len(matches) != 1:
fail("service catalog did not contain exactly one matching Secrets Manager endpoint")
return matches[0]
def decode_secret(document: Mapping[str, Any], name: str, limit: int) -> bytes:
# GET /v1/{name} returns the current value inside ``version`` while
# GET /v1/{name}/versions/{id} returns a version object directly.
payload: Mapping[str, Any] = document
version = document.get("version")
if isinstance(version, dict):
payload = version
encoded = payload.get("value")
if not isinstance(encoded, str):
fail(f"secret {name} response omitted base64 value")
try:
value = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
fail(f"secret {name} has invalid base64 encoding")
if not value:
fail(f"secret {name} is empty")
if len(value) > limit:
fail(f"secret {name} exceeds its configured limit")
if b"\x00" in value or b"\n" in value or b"\r" in value:
fail(f"secret {name} cannot be represented as a dotenv value")
return value
def fetch_selectel(
config: Mapping[str, Any],
specs: Mapping[str, Mapping[str, Any]],
environ: Mapping[str, str],
client_factory: Callable[..., HTTPClient] = HTTPClient,
) -> dict[str, bytes]:
selectel = _object(config.get("selectel"), "selectel")
_only_keys(
selectel,
{
"account_id",
"username",
"project_name",
"region",
"interface",
"password_file",
"password_env",
"identity_url",
"secrets_url",
"ca_file",
},
"selectel",
)
http = _object(config.get("http", {}), "http")
_only_keys(http, {"timeout_seconds", "retries", "max_response_bytes"}, "http")
timeout = http.get("timeout_seconds", 10)
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not 0.1 <= timeout <= 60:
fail("http.timeout_seconds must be from 0.1 through 60")
retries = _bounded_int(http.get("retries", 3), "http.retries", 0, 8)
response_limit = _bounded_int(
http.get("max_response_bytes", MAX_HTTP_BYTES),
"http.max_response_bytes",
1024,
4 * MAX_HTTP_BYTES,
)
cafile = selectel.get("ca_file")
if cafile is not None and (not isinstance(cafile, str) or not cafile):
fail("selectel.ca_file must be a non-empty string")
client = client_factory(
timeout=float(timeout),
retries=retries,
max_response_bytes=response_limit,
cafile=cafile,
)
password = credential_value(selectel, environ)
token, catalog = project_token_and_catalog(client, selectel, password)
region = _required_string(selectel, "region", "selectel")
interface = selectel.get("interface", "public")
if interface not in {"public", "internal"}:
fail("selectel.interface must be public or internal")
override = selectel.get("secrets_url")
if override is not None and (not isinstance(override, str) or not override):
fail("selectel.secrets_url must be a non-empty string")
base_url = override.rstrip("/") if override else secrets_endpoint(catalog, region, interface)
values: dict[str, bytes] = {}
fetched: dict[tuple[str, int | None], bytes] = {}
for canonical, spec in specs.items():
if "literal" in spec:
values[canonical] = b""
continue
remote = _required_string(spec, "remote", f"secrets.{canonical}")
version = spec.get("version")
version_id: int | None = None
if version is not None:
version_id = _bounded_int(
version, f"secrets.{canonical}.version", 1, 2_147_483_647
)
cache_key = (remote, version_id)
if cache_key not in fetched:
path = f"/v1/{urllib.parse.quote(remote, safe='')}"
if version_id is not None:
path += f"/versions/{version_id}"
try:
result = client.request(
"GET",
base_url + path,
headers={"X-Auth-Token": token, "Accept": "application/json"},
expected=frozenset({200}),
)
document = parse_json_response(result, f"secret {canonical} response")
fetched[cache_key] = decode_secret(
document, canonical, MAX_SECRET_BYTES
)
except LoaderError as exc:
fail(f"cannot load {canonical}: {exc}")
limit = _bounded_int(
spec.get("max_bytes", MAX_SECRET_BYTES),
f"secrets.{canonical}.max_bytes",
1,
MAX_SECRET_BYTES,
)
value = fetched[cache_key]
if len(value) > limit:
fail(f"secret {canonical} exceeds its configured limit")
values[canonical] = value
return values
def parse_dotenv(path: Path, expected: set[str], max_bytes: int) -> dict[str, bytes]:
require_private_regular_file(path, "fallback dotenv")
raw = read_limited(path, max_bytes, "fallback dotenv")
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
fail("fallback dotenv is not valid UTF-8")
values: dict[str, bytes] = {}
for number, line in enumerate(text.splitlines(), 1):
if not line or line.startswith("#"):
continue
match = DOTENV_LINE_RE.fullmatch(line)
if not match:
fail(f"fallback dotenv has invalid syntax at line {number}")
name, encoded_value = match.groups()
if name not in expected:
fail(f"fallback dotenv contains undeclared key {name}")
if name in values:
fail(f"fallback dotenv contains duplicate key {name}")
if encoded_value.startswith('"'):
try:
decoded = json.loads(encoded_value)
except json.JSONDecodeError:
fail(f"fallback dotenv has invalid quoted value at line {number}")
if not isinstance(decoded, str):
fail(f"fallback dotenv has invalid quoted value at line {number}")
value = decoded.encode("utf-8")
elif encoded_value.startswith("'"):
if len(encoded_value) < 2 or not encoded_value.endswith("'"):
fail(f"fallback dotenv has invalid quoted value at line {number}")
value = encoded_value[1:-1].encode("utf-8")
else:
if any(character.isspace() for character in encoded_value) or any(
character in encoded_value for character in ("'", '"', "`", "$", "\\")
):
fail(f"fallback dotenv requires quoting at line {number}")
value = encoded_value.encode("utf-8")
if not value or b"\x00" in value or b"\n" in value or b"\r" in value:
fail(f"fallback dotenv has an empty or unsafe value for {name}")
values[name] = value
missing = sorted(expected - set(values))
if missing:
fail(f"fallback dotenv is missing declared keys: {', '.join(missing)}")
return values
def validate_specs(config: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
raw_specs = _object(config.get("secrets"), "secrets")
if not raw_specs:
fail("secrets must not be empty")
specs: dict[str, dict[str, Any]] = {}
for canonical, raw_spec in raw_specs.items():
if not isinstance(canonical, str) or not ENV_NAME_RE.fullmatch(canonical):
fail("every canonical secret name must be an uppercase environment name")
spec = _object(raw_spec, f"secrets.{canonical}")
_only_keys(
spec,
{"remote", "consumers", "max_bytes", "version", "literal"},
f"secrets.{canonical}",
)
has_remote = "remote" in spec
has_literal = "literal" in spec
if has_remote == has_literal:
fail(f"secrets.{canonical} must set exactly one of remote or literal")
if has_literal and spec["literal"] != "":
fail(f"secrets.{canonical}.literal may only be an empty string")
consumers = spec.get("consumers")
if not isinstance(consumers, list) or not consumers:
fail(f"secrets.{canonical}.consumers must be a non-empty array")
if len(consumers) != len(set(item for item in consumers if isinstance(item, str))):
fail(f"secrets.{canonical}.consumers contains duplicates or invalid values")
for consumer in consumers:
if not isinstance(consumer, str) or not SERVICE_NAME_RE.fullmatch(consumer):
fail(f"secrets.{canonical}.consumers contains an invalid service name")
specs[canonical] = spec
return specs
def dotenv_quote(value: bytes, name: str) -> str:
try:
text = value.decode("utf-8")
except UnicodeDecodeError:
fail(f"secret {name} is not valid UTF-8")
return json.dumps(text, ensure_ascii=False)
def materialize(runtime_dir: Path, specs: Mapping[str, Mapping[str, Any]], values: Mapping[str, bytes]) -> None:
try:
runtime_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
if runtime_dir.is_symlink():
fail("runtime directory must not be a symlink")
os.chmod(runtime_dir, 0o700)
except OSError as exc:
fail(f"cannot prepare runtime directory: {exc.strerror or exc.__class__.__name__}")
consumers = sorted(
{consumer for spec in specs.values() for consumer in spec["consumers"]}
)
staged: list[tuple[Path, Path]] = []
try:
for consumer in consumers:
lines = [
f"{name}={dotenv_quote(values[name], name)}\n"
for name, spec in sorted(specs.items())
if consumer in spec["consumers"]
]
descriptor, temporary = tempfile.mkstemp(
prefix=f".{consumer}.", suffix=".tmp", dir=runtime_dir
)
temporary_path = Path(temporary)
try:
os.chmod(temporary_path, 0o600)
stream = os.fdopen(descriptor, "w", encoding="utf-8", newline="\n")
descriptor = -1
with stream:
stream.writelines(lines)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
if descriptor >= 0:
os.close(descriptor)
raise
staged.append((temporary_path, runtime_dir / f"{consumer}.env"))
for temporary_path, destination in staged:
os.replace(temporary_path, destination)
value_paths: dict[str, Path] = {}
for name, value in sorted(values.items()):
descriptor, temporary = tempfile.mkstemp(
prefix=f".{name}.", suffix=".tmp", dir=runtime_dir
)
temporary_path = Path(temporary)
try:
# Compose implements local secrets as bind mounts. The protected
# 0700 parent prevents host users from traversing to this 0444
# file while allowing a non-root container UID to read its mount.
os.chmod(temporary_path, 0o444)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(value)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
if descriptor >= 0:
os.close(descriptor)
temporary_path.unlink(missing_ok=True)
raise
destination = runtime_dir / name
os.replace(temporary_path, destination)
value_paths[name] = destination
manifest_lines = [
f"{name}={path.resolve()}\n" for name, path in sorted(value_paths.items())
]
descriptor, temporary = tempfile.mkstemp(
prefix=".manifest.", suffix=".tmp", dir=runtime_dir
)
manifest_path = Path(temporary)
try:
os.chmod(manifest_path, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
descriptor = -1
stream.writelines(manifest_lines)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
if descriptor >= 0:
os.close(descriptor)
manifest_path.unlink(missing_ok=True)
raise
os.replace(manifest_path, runtime_dir / "manifest")
if hasattr(os, "O_DIRECTORY"):
directory_fd = os.open(runtime_dir, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
except OSError as exc:
fail(f"cannot atomically materialize service files: {exc.strerror or exc.__class__.__name__}")
finally:
for temporary_path, _ in staged:
try:
temporary_path.unlink(missing_ok=True)
except OSError:
pass
def run(
config_path: Path,
*,
runtime_override: Path | None = None,
environ: Mapping[str, str] | None = None,
client_factory: Callable[..., HTTPClient] = HTTPClient,
) -> list[str]:
os.umask(0o077)
environment = os.environ if environ is None else environ
config = load_json(config_path)
_only_keys(config, {"version", "mode", "runtime_dir", "http", "selectel", "file", "secrets"}, "configuration")
if config.get("version") != 1:
fail("configuration.version must be 1")
mode = config.get("mode")
if mode not in {"selectel", "file"}:
fail("configuration.mode must explicitly be selectel or file")
specs = validate_specs(config)
if runtime_override is None:
configured_runtime = config.get("runtime_dir")
if not isinstance(configured_runtime, str) or not configured_runtime:
fail("configuration.runtime_dir must be a non-empty string")
runtime_dir = Path(configured_runtime)
else:
runtime_dir = runtime_override
if not runtime_dir.is_absolute():
fail("runtime directory must be an absolute path")
if mode == "selectel":
if "file" in config:
fail("file settings are forbidden in selectel mode")
values = fetch_selectel(config, specs, environment, client_factory)
else:
if "selectel" in config or "http" in config:
fail("selectel and http settings are forbidden in file mode")
file_config = _object(config.get("file"), "file")
_only_keys(file_config, {"path", "max_bytes"}, "file")
source = Path(_required_string(file_config, "path", "file"))
if not source.is_absolute():
fail("file.path must be absolute")
max_bytes = _bounded_int(
file_config.get("max_bytes", MAX_CONFIG_BYTES),
"file.max_bytes",
1,
4 * MAX_CONFIG_BYTES,
)
expected = {name for name, spec in specs.items() if "literal" not in spec}
values = parse_dotenv(source, expected, max_bytes)
values.update(
{name: b"" for name, spec in specs.items() if "literal" in spec}
)
for canonical, value in values.items():
limit = _bounded_int(
specs[canonical].get("max_bytes", MAX_SECRET_BYTES),
f"secrets.{canonical}.max_bytes",
1,
MAX_SECRET_BYTES,
)
if len(value) > limit:
fail(f"secret {canonical} exceeds its configured limit")
materialize(runtime_dir, specs, values)
return sorted({consumer for spec in specs.values() for consumer in spec["consumers"]})
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Materialize per-service secret dotenv files")
parser.add_argument("--config", required=True, type=Path)
parser.add_argument("--runtime-dir", type=Path)
arguments = parser.parse_args(argv)
try:
consumers = run(arguments.config, runtime_override=arguments.runtime_dir)
except LoaderError as exc:
print(f"secrets-loader: {exc}", file=sys.stderr)
return 1
print(f"secrets-loader: materialized {len(consumers)} service file(s)", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())