Проект разделен на два репозитория
This commit is contained in:
@@ -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 0–15 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())
|
||||
Reference in New Issue
Block a user