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

This commit is contained in:
mi
2026-08-19 18:24:00 +03:00
parent bbef7a30c9
commit c7a80e7256
103 changed files with 3457 additions and 3725 deletions
+117
View File
@@ -0,0 +1,117 @@
# Release #0: безопасный порядок развертывания
Этот файл больше не является журналом реальных адресов, SSH-ключей и локальных
путей. Фактические значения инфраструктуры хранятся в защищённой CMDB/ops wiki,
а не в Git.
## 1. Подготовка ВМ
Разрешите извне только 80/443 и SSH из административной сети. PostgreSQL
доступен ВМ только через приватную сеть.
```sh
scp -i <DEPLOY_SSH_KEY> deployment/scripts/setup-vm.sh \
<CLOUD_USER>@<VM_IP>:/tmp/setup-vm.sh
ssh -i <DEPLOY_SSH_KEY> <CLOUD_USER>@<VM_IP>
sudo chmod 0755 /tmp/setup-vm.sh
sudo /tmp/setup-vm.sh
```
После создания пользователя `deploy` проверьте отдельную SSH-сессию до
отключения root login. Не копируйте приватный ключ на ВМ.
## 2. Доставка приложения
Предпочтительно использовать Git либо архив без runtime-данных:
```sh
tar -C HAN_chat_specification/codebase/backend \
--exclude='.env' \
--exclude='secrets' \
--exclude='backups' \
-czf han-chat-backend.tar.gz .
scp -i <DEPLOY_SSH_KEY> han-chat-backend.tar.gz deploy@<VM_IP>:/tmp/
```
На ВМ:
```sh
sudo install -d -o deploy -g deploy -m 0755 /opt/han-chat/backend
sudo -u deploy tar -C /opt/han-chat/backend \
-xzf /tmp/han-chat-backend.tar.gz
cd /opt/han-chat/backend
sudo find . -type f \( -name '*.sh' -o -name 'validate-env' \
-o -name 'han-secrets' -o -name 'han-compose' \) \
-exec dos2unix {} +
sudo chmod 0755 scripts/validate-env deployment/scripts/*.sh \
deployment/secrets/han-secrets deployment/secrets/han-compose \
redis/scripts/*.sh nginx/scripts/*.sh
sudo deployment/scripts/setup-vm.sh
```
## 3. Несекретная конфигурация
`.env` создаётся на самой ВМ из `.env.example` и содержит только URL, resource
names, feature flags и `SECRETS_SOURCE`. Его разрешено передавать как обычный
config, но запрещено добавлять credential-bearing DSN, password, token и key.
```sh
cp .env.example .env
chmod 0600 .env
nano .env
./scripts/validate-env .env
```
## 4. Selectel Secrets Manager
Выполните `deployment/secrets/SELECTEL_RUNBOOK.ru.md`:
1. отдельный проект и service user;
2. provider secrets и audit alerts;
3. root-only JSON-карта;
4. encrypted systemd credential;
5. `SECRETS_SOURCE=selectel`.
Не передавайте значение секрета аргументом команды, через `export`, тикет или
shell history. Старые значения из прежнего `.env` после cutover ротируются.
## 5. Проверка и запуск
```sh
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
python3 -m unittest discover -s tests -v
sudo deployment/secrets/han-compose build --pull
sudo deployment/scripts/migrate.sh
sudo deployment/secrets/han-compose up -d --wait
sudo deployment/scripts/smoke.sh
```
Для диагностики используйте `han-compose ps` и ограниченные logs. Не выводите
resolved Compose config, `docker inspect` environment, полный `env` или secret
files.
## 6. Откат
Откат приложения использует immutable image/release ID и подтверждённую
совместимость схемы:
```sh
sudo SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
deployment/scripts/rollback.sh <PREVIOUS_RELEASE>
```
Откат секрета выполняется активацией предыдущей версии в Selectel, повторным
sync и пересозданием только затронутых сервисов. Snapshot старого `.env` не
создаётся.
## 7. Break-glass
Только при подтверждённом инциденте доставьте root-only recovery file из
защищённой офлайн-копии, установите `SECRETS_SOURCE=file`, выполните
sync/validate/recreate и зафиксируйте событие. Автоматический fallback запрещён.
После восстановления Selectel верните штатный режим и удалите recovery file.
+405
View File
@@ -0,0 +1,405 @@
# #1 SMS OTP deploy
Безопасный порядок развёртывания `sms-service`/worker на существующей ВМ и включения реальной OTP-доставки: сначала подготовить PostgreSQL и секреты, затем запустить новый контур при `mock=true`, проверить i-Digital и только после этого переключить Keycloak.
Старую сборку Keycloak после expand-миграции возвращать нельзя. Аварийный откат выполняется переключением новой сборки обратно в mock-режим.
## 0. До начала
- Получить у i-Digital:
- `TOKEN_1`;
- согласованное имя отправителя;
- согласованный текст `auth_otp`;
- подтверждённый source IP для callback;
- регистрацию статического egress IP ВМ.
- Создать PITR marker/backup managed PostgreSQL.
- Скопировать `/opt/han-chat/backend/.env` в защищённое место вне каталога релиза.
- Оставить `KEYCLOAK_OTP_MOCK_ENABLED=true` до последнего этапа.
- На production-like при mock-режиме оставить `KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true`.
- Подтвердить у Direct актуальность IP `185.203.96.7`, указанного в `codebase/backend/nginx/templates/site-tls.conf.template`. Если IP другой — обновить allowlist до сборки nginx.
## 1. Создать пользователя и схему PostgreSQL
### 1.1. Создать пользователя
В интерфейсе Selectel создать отдельного пользователя:
```text
sms_user
```
Использовать случайный пароль не короче 32 символов.
### 1.2. Создать схему
Подключиться к `han_chat` под `dbAdmin` и выполнить:
```sql
GRANT CONNECT ON DATABASE han_chat TO sms_user;
GRANT CREATE ON DATABASE han_chat TO sms_user;
CREATE SCHEMA IF NOT EXISTS sms AUTHORIZATION sms_user;
REVOKE ALL ON SCHEMA sms FROM PUBLIC;
ALTER ROLE sms_user IN DATABASE han_chat SET search_path TO sms, public;
```
### 1.3. Проверить
Переподключиться к БД как `sms_user`:
```sql
SELECT current_user;
SHOW search_path;
SELECT
nspname,
pg_get_userbyid(nspowner) AS owner
FROM pg_namespace
WHERE nspname = 'sms';
```
Ожидаемый результат:
- `current_user = sms_user`;
- `search_path = sms, public`;
- владелец схемы `sms``sms_user`.
Не выдавать `sms_user` права на схемы `han_app` и `keycloak`.
## 2. Заполнить `.env` на ВМ
Файл:
```text
/opt/han-chat/backend/.env
```
Добавить или обновить:
```dotenv
SMS_SERVICE_IMAGE=han-chat-sms-service:local
SMS_DATABASE_URL=postgresql+asyncpg://sms_user:<URL_ENCODED_PASSWORD>@<PG_HOST>:<PG_PORT>/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
SMS_SERVICE_TOKEN=<openssl rand -hex 32>
KEYCLOAK_SMS_SERVICE_TOKEN=<ТОЧНО ТО ЖЕ ЗНАЧЕНИЕ>
IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru
IDGTL_SMS_API_KEY=<ГОТОВЫЙ TOKEN_1 БЕЗ ПОВТОРНОГО BASE64>
IDGTL_SMS_CALLBACK_PUBLIC_URL=https://<PUBLIC_HOST>/callbacks/idgtl/sms
IDGTL_SMS_CALLBACK_USERNAME=<openssl rand -hex 16>
IDGTL_SMS_CALLBACK_PASSWORD=<openssl rand -hex 32>
NGINX_RATE_LIMIT_SMS_CALLBACK=120r/m
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true
```
Важно:
- пароль БД необходимо URL-encode, если он содержит специальные символы;
- `SMS_SERVICE_TOKEN` и `KEYCLOAK_SMS_SERVICE_TOKEN` должны совпадать;
- `IDGTL_SMS_API_KEY` — уже готовое значение Basic API key `TOKEN_1`, повторно кодировать его нельзя;
- `KEYCLOAK_OTP_HMAC_KEY` во время rollout не менять.
### 2.1. Проверить egress IP
Из каталога `/opt/han-chat/backend`:
```bash
docker compose --env-file .env --profile ops run --rm \
--entrypoint curl toolbox -fsS https://api.ipify.org
```
Полученный IP передать Direct для allowlist. При динамическом IP сначала настроить статический IP/NAT.
### 2.2. Проверить конфигурацию
```bash
cd /opt/han-chat/backend
./scripts/validate-env .env
docker compose --env-file .env config --quiet
docker compose --env-file .env config --services
```
## 3. Скопировать и собрать release
Копирование проекта выполняется по инструкции `deploy-steps.md`.
Сначала выполнить `rsync` с флагом `-n` и проверить список изменений. Убедиться, что исключены:
```text
.env
secrets/
*.crt
*.pem
*.key
```
После проверки повторить `rsync` без `-n`.
На ВМ:
```bash
cd /opt/han-chat/backend
find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} +
chmod +x scripts/validate-env deployment/scripts/*.sh nginx/scripts/*.sh
./scripts/validate-env .env
docker compose --env-file .env build --pull \
api-backend sms-service keycloak frontend-static nginx
```
На этом этапе `KEYCLOAK_OTP_MOCK_ENABLED` всё ещё должен быть `true`.
## 4. Применить миграции
Перед миграцией создать PITR marker у провайдера БД.
```bash
cd /opt/han-chat/backend
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
```
Команда применит:
- migration `0005_otp_settings` для `han_app`;
- migration `0001_initial` для схемы `sms`;
- migration `0002_seed` для схемы `sms`;
- остальные штатные migrations проекта.
При необходимости применить production-like settings:
```bash
docker compose --env-file .env --profile ops run --rm seed-settings
```
Проверить версии:
```sql
SELECT version_num FROM han_app.alembic_version;
SELECT version_num FROM sms.alembic_version;
```
Ожидается:
```text
han_app: 0005_otp_settings
sms: 0002_seed
```
## 5. Записать согласованные sender и SMS-шаблон
Миграция намеренно создаёт placeholder. Пока он не заменён, `sms-service` будет возвращать `not_ready`.
Подключиться как `sms_user` и выполнить, подставив согласованные значения:
```sql
UPDATE sms.sms_setting
SET setting_value = to_jsonb('<APPROVED_SENDER>'::text),
updated_at = now()
WHERE setting_key = 'provider.idgtl.default_sender_name';
UPDATE sms.sms_template
SET body_template = 'Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.',
sender_name = NULL,
approved_at = now(),
updated_at = now(),
created_by = 'ops-approved'
WHERE code = 'auth_otp'
AND channel = 'SMS'
AND locale = 'ru'
AND version = 1;
```
Если оператор согласовал другой текст, использовать именно его. В тексте должны остаться ровно два placeholders:
```text
{code}
{ttl_min}
```
Для OTP должно сохраняться:
```text
max_parts = 1
```
Проверить:
```sql
SELECT
code,
channel,
locale,
version,
body_template,
sender_name,
max_parts,
is_active,
approved_at
FROM sms.sms_template
WHERE code = 'auth_otp';
SELECT setting_key, setting_value
FROM sms.sms_setting
ORDER BY setting_key;
```
Должна существовать ровно одна active+approved версия `auth_otp`, а placeholder имени отправителя должен быть заменён.
## 6. Запустить SMS-контур при `mock=true`
```bash
cd /opt/han-chat/backend
docker compose --env-file .env up -d sms-service sms-worker
docker compose --env-file .env ps sms-service sms-worker
docker compose --env-file .env logs --since=10m sms-service sms-worker
```
Ожидается:
- `sms-service` — healthy;
- worker запущен;
- отсутствуют ошибки Direct `401`/`402`;
- отсутствуют contract errors;
- отсутствуют необъяснённые `uncertain`.
Затем запустить обновлённые смежные сервисы, не выключая mock:
```bash
docker compose --env-file .env up -d --force-recreate \
api-backend keycloak frontend-static nginx
docker compose --env-file .env exec -T nginx nginx -t -c /tmp/nginx.conf
deployment/scripts/smoke.sh
```
Первый запуск новой сборки Keycloak применит Liquibase expand migration. Старые незавершённые OTP challenges будут помечены истёкшими, поэтому запускать Keycloak лучше в период низкой активности.
## 7. Проверить i-Digital до включения real mode
Через внутренний endpoint:
```text
POST /internal/sms/v1/send
```
заказать одну SMS на контролируемый номер.
Требования к тесту:
- использовать уникальный `idempotency_key`;
- не записывать service token и OTP в shell history;
- JSON body создать во временном файле с правами `600`;
- после теста удалить временный файл.
Проверить журнал:
```sql
SELECT
id,
created_at,
phone_masked,
send_status,
delivery_status,
provider_message_id,
provider_error_code,
attempt_count,
callback_last_at
FROM sms.sms_outbound_message
ORDER BY created_at DESC
LIMIT 10;
```
Ожидается:
1. После заказа создана одна строка.
2. `send_status` переходит в `accepted`.
3. `provider_message_id` заполнен.
4. Callback меняет `delivery_status` на `sent`/`delivered`.
5. Повтор идентичного запроса возвращает тот же `sms_message_id` и не создаёт вторую SMS.
Проверить edge:
- публичный `/internal/sms/*` возвращает `404`;
- callback не с IP Direct возвращает `403`;
- реальный callback Direct проходит IP allowlist и Basic auth.
## 8. Включить реальные SMS
Только после успешной тестовой отправки изменить:
```dotenv
KEYCLOAK_OTP_MOCK_ENABLED=false
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false
KEYCLOAK_OTP_MOCK_CODE=
```
Применить:
```bash
cd /opt/han-chat/backend
./scripts/validate-env .env
docker compose --env-file .env up -d \
--no-deps \
--force-recreate keycloak
docker compose --env-file .env ps keycloak
docker compose --env-file .env logs --since=10m \
keycloak sms-service sms-worker
```
Проверить полный пользовательский сценарий:
1. Ввод номера телефона.
2. Получение реальной SMS.
3. Неверный OTP отклоняется.
4. Верный OTP авторизует пользователя.
5. Resend создаёт новый challenge.
6. Старый challenge получает `superseded`.
7. Старый код больше не принимается.
8. OTP истекает через 60 секунд.
9. Работают лимиты отправок и проверок.
10. Уже active challenge продолжает локально проверяться при временно остановленном worker.
## 9. Аварийный откат
Не выполнять:
- downgrade Alembic;
- downgrade Liquibase;
- возврат старой сборки Keycloak.
После expand migration старая сборка Keycloak несовместима с новыми обязательными полями challenge.
Безопасный rollback — оставить новую сборку и вернуть mock:
```dotenv
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_CODE=<НЕПУБЛИЧНЫЙ 6-ЗНАЧНЫЙ КОД>
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true
```
```bash
cd /opt/han-chat/backend
./scripts/validate-env .env
docker compose --env-file .env up -d \
--no-deps \
--force-recreate keycloak
```
`sms-service` и worker можно оставить запущенными для обработки callback и reconciliation. Новые SMS-заказы от Keycloak прекратятся.
Записи со статусом `uncertain` автоматически не переотправлять — их необходимо разбирать вручную.
@@ -0,0 +1,960 @@
# Usefull commands
## Скопировать файл по технологии через архив
cd C:\Users\MI\Documents\Assistent\HAN_chat_specification
$Hotfix = "bug-v15"
tar -czf "vm2-$Hotfix.tar.gz" -C .\codebase `
services/deployment/scripts/setup-vm.sh
Get-FileHash "vm2-$Hotfix.tar.gz" -Algorithm SHA256
scp -i C:\Users\MI\.ssh\han_vm2_deploy `
"vm2-$Hotfix.tar.gz" `
deploy@135.106.179.209:/var/lib/han-deploy/incoming/
services/docker-compose.yml
HAN_chat_specification\codebase\services\docker-compose.yml
HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh
HOTFIX='bug-v15'
EXPECTED_SHA256='BF85F75F45F63BFB8310C6E0B835276DE7EBF316180120F6383590C19E44FEE4'
ARCHIVE="/var/lib/han-deploy/incoming/vm2-${HOTFIX}.tar.gz"
printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check -
tar -tzf "$ARCHIVE"
STAGING="$(mktemp -d /opt/han-chat/.nginx-hotfix.XXXXXX)"
tar -xzf "$ARCHIVE" -C "$STAGING" --no-same-owner --no-same-permissions
install -m 0644 -o root -g root "$STAGING/services/deployment/scripts/setup-vm.sh" /opt/han-chat/services/deployment/scripts/setup-vm.sh
sed -i 's/\r$//' ./deployment/scripts/setup-vm.sh
find -type f -exec file {} \; | grep -i 'CRLF'
## Перезапустить контейнер
/usr/local/sbin/han-vm2-compose up -d --force-recreate freshclam
# Создание докер-образов
docker login cr.selcloud.ru
cd /mnt/c/Users/MI/Documents/Assistent/.../codebase/services/bitrix-sync
docker build -t han-bitrix-sync:1.0.3 .
REGISTRY=cr.selcloud.ru/han-images
docker tag han-bitrix-sync:1.0.3 $REGISTRY/han-bitrix-sync:1.0.3
docker push $REGISTRY/han-bitrix-sync:1.0.3
docker image inspect $REGISTRY/han-bitrix-sync:1.0.3 --format '{{index .RepoDigests 0}}'
cd /mnt/c/Users/MI/Documents/Assistent/.../codebase/services/message-safety
docker build -t han-message-safety:1.0.2 .
REGISTRY=cr.selcloud.ru/han-images # ваш registry
docker tag han-message-safety:1.0.2 $REGISTRY/han-message-safety:1.0.2
docker push $REGISTRY/han-message-safety:1.0.2
docker image inspect $REGISTRY/han-message-safety:1.0.2 --format '{{index .RepoDigests 0}}'
## Чтобы собрать sha256 с остальных образов, их надо закачать
docker pull nginxinc/nginx-unprivileged:1.27
docker pull redis:7.4
docker pull clamav/clamav:1.4
docker pull otel/opentelemetry-collector-contrib:0.117.0
docker image inspect nginxinc/nginx-unprivileged:1.27 --format '{{index .RepoDigests 0}}'
docker image inspect redis:7.4 --format '{{index .RepoDigests 0}}'
docker image inspect clamav/clamav:1.4.6 --format '{{index .RepoDigests 0}}'
docker image inspect otel/opentelemetry-collector-contrib:0.117.0 --format '{{index .RepoDigests 0}}'
# Генерация ключей для новых пользователей и первичная настройка ВМ2
ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_deploy -C "han-vm2-deploy"
ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_admin -C "han-vm2-break-glass-admin"
scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\Documents\Assistent\HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh root@135.106.179.209:/root/setup-vm2.sh
scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\.ssh\han_vm2_deploy.pub C:\Users\MI\.ssh\han_vm2_admin.pub root@135.106.179.209:/root/
На VM2 в текущей root-сессии задайте реальные CIDR. `/0` скрипт отклоняет:
```sh
ssh -i C:\Users\MI\.ssh\hansel root@135.106.179.209
sed -i 's/\r$//' /root/setup-vm2.sh
install -d -m 0700 -o root -g root /root/bootstrap
install -m 0600 -o root -g root /root/han_vm2_deploy.pub /root/bootstrap/deploy.pub
install -m 0600 -o root -g root /root/han_vm2_admin.pub /root/bootstrap/admin.pub
chmod 0700 /root/setup-vm2.sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='192.168.0.0/24' \
/root/setup-vm2.sh
```
1 В текущей root-сессии задайте `admin` отдельный сложный sudo-пароль. Он не
разрешает password SSH: пароль нужен только после входа по admin key:
```sh
passwd admin
```
2 Не закрывая root-сессию, проверьте отдельные SSH-ключи deploy и admin.
ssh -i C:\Users\MI\.ssh\han_vm2_deploy deploy@135.106.179.209
ssh -i C:\Users\MI\.ssh\han_vm2_admin admin@135.106.179.209
В сессии admin проверьте sudo -v и sudo -i, затем завершите root shell:
sudo -v # проверить, что пароль admin принимается
sudo -i # открыть root shell (приглашение обычно root@...)
id # убедиться, что uid=0
exit # ← вот это «завершите root shell» — вернуться к admin@
# Только после успешной проверки `deploy`, `admin` и `sudo` повторите на VM2
под `root`:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='192.168.0.0/24' \
HARDEN_SSH=true \
SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh
```
Это добавит `PermitRootLogin no` и `AllowUsers deploy admin`.
Публичные bootstrap-копии после проверки можно удалить под `admin`:
```sh
sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub
```
# Проверка наличия сетевого интерфейса для ВМ и его создание
ls -la /etc/netplan
netplan get
ip route
cat >/etc/netplan/60-private-network.yaml <<'EOF'
network:
version: 2
ethernets:
eth1:
dhcp4: false
addresses:
- 192.168.0.4/24
optional: true
EOF
chmod 0600 /etc/netplan/60-private-network.yaml
netplan generate
netplan try
Подтвердите конфигурацию в течение тайм-аута. Затем проверьте:
ip -br -4 address show eth1
ip route
ping -c 3 192.168.0.210
# nginx правим разрешенные адреса
nginx/allowlists/
# Копируем проект
(Текущая команда копирует весь каталог ради простоты формирования единого архива, но технической необходимости в исходниках приложений нет)
На локальном компьютере из каталога `HAN_chat_specification`:
```powershell
$Release = "1.0.3"
tar --exclude=services/.env `
--exclude='services/**/__pycache__' `
--exclude='services/**/.pytest_cache' `
--exclude='services/**/.ruff_cache' `
-czf "vm2-services-$Release.tar.gz" -C C:\Users\MI\Documents\Assistent\HAN_chat_specification\codebase services
Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256
scp -i C:\Users\MI\.ssh\han_vm2_deploy "vm2-services-$Release.tar.gz" `
deploy@135.106.179.209:/var/lib/han-deploy/incoming/
```
Под `deploy` на VM2 вычислите checksum. Значение должно совпасть с локальным:
```sh
RELEASE='1.0.2'
cd /var/lib/han-deploy/incoming
sha256sum "vm2-services-${RELEASE}.tar.gz"
tar -tzf "vm2-services-${RELEASE}.tar.gz"
```
На этом действия `deploy` с файлами заканчиваются. Не распаковывайте релиз
через `sudo` и не копируйте его в production от имени `deploy`.
# Активация и установка файлов под `root`
ssh -i C:\Users\MI\.ssh\han_vm2_admin admin@135.106.179.209
sudo -i
id
Должно быть uid=0(root).
Под `root` ещё раз сверьте ожидаемый SHA-256 и список архива. Не продолжайте,
если архив содержит абсолютные пути, `..`, symlink/hardlink или лишний проект:
```sh
RELEASE='1.0.3'
EXPECTED_SHA256='0FC8F2B85EC39EE41BD6D3E3789963A4D985B3E2E9638E50BEAF978EA6A46271'
ARCHIVE="/var/lib/han-deploy/incoming/vm2-services-${RELEASE}.tar.gz"
printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check -
tar -tvzf "$ARCHIVE"
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then
echo 'ОШИБКА: архив содержит небезопасный путь или .env' >&2
exit 1
fi
if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then
echo 'ОШИБКА: архив содержит файлы вне каталога services' >&2
exit 1
fi
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ { found=1 } END { exit !found }'; then
echo 'ОШИБКА: архив содержит symlink или hardlink' >&2
exit 1
fi
install -d -m 0755 -o root -g root /opt/han-chat/services
STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)"
tar --extract --gzip --file "$ARCHIVE" \
--directory "$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"
chmod 0755 /opt/han-chat/services/deployment/preflight.sh
```
cd /opt/han-chat/services/
sudo apt update
sudo apt install -y dos2unix
find . -type f \( \
-name '*.sh' -o -name '*.py' -o -name '*.service' -o -name '*.sudoers' -o -name 'han-compose' -o -name 'han-secrets' -o -name 'han-message-safety-mode' -o -name '*.yml' -o -name '*.conf*' -o -name 'preflight.sh' -o -name '*.yaml' -o -name '*.md' -o -name '*.toml' -o -name 'Dockerfile' -o -name '*.acl*' \
\) -exec dos2unix {} +
find -type f -exec file {} \; | grep -i 'CRLF'
sudo apt remove -y dos2unix
sudo apt purge -y dos2unix
# Если перезалили
scp /opt/han-chat/services/deployment/scripts/setup-vm.sh /root/setup-vm2.sh
chmod 0700 /root/setup-vm2.sh
Была проблема с каретками, полечилась так:
(sed -i 's/\r$//' \
/opt/han-chat/services/deployment/deploy-message-safety-mode.sudoers
install -m 0440 -o root -g root \
/opt/han-chat/services/deployment/deploy-message-safety-mode.sudoers \
/etc/sudoers.d/deploy-message-safety-mode
visudo -cf /etc/sudoers.d/deploy-message-safety-mode)
Повторите setup под `root`: теперь он установит helpers и units из активного
релиза. Приложение всё ещё не запускается:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='192.168.0.0/24' \
HARDEN_SSH=true \
SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh
```
# Несекретная конфигурация и Selectel под `root`
`APP_ENV` определяет имя loader config. При значении из `.env.example`
`APP_ENV=production-like` файл обязан называться
`/etc/han/secrets/vm2-production-like.selectel.json`:
```sh
cd /opt/han-chat/services
install -m 0600 -o root -g root .env.example .env
editor .env
# я скопировал и вставил значения из локального
sed -i 's/\r$//' .env
install -m 0600 -o root -g root \
deployment/secrets/config.example.json \
/etc/han/secrets/vm2-production-like.selectel.json
editor /etc/han/secrets/vm2-production-like.selectel.json
```
sudo sed -i 's/\r$//' /etc/han/secrets/vm2-production-like.selectel.json
ls /etc/han/secrets/vm2-production-like.selectel.json
# Для заполнения секретов нужно создать сертификаты для ВМ1-ВМ2.
### 1. Создать CA (один раз)
На безопасной машине (не обязательно VM2):
```bash
mkdir -p ~/han-internal-ca && cd ~/han-internal-ca
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-subj "/CN=HAN Internal CA" \
-out ca.crt
```
`ca.key` храните только у себя; на ВМ не кладите.
### 2. Выпустить серверный сертификат VM2
```bash
openssl genrsa -out vm2-processing.key 2048
cat > vm2-processing.ext <<'EOF'
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = processing.internal
EOF
openssl req -new -key vm2-processing.key \
-subj "/CN=processing.internal" \
-out vm2-processing.csr
openssl x509 -req -in vm2-processing.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out vm2-processing.crt -days 825 -sha256 \
-extfile vm2-processing.ext
```
Проверка:
```bash
openssl x509 -in vm2-processing.crt -noout -text | grep -A2 'Subject Alternative Name'
```
### 3. Разложить секреты
**VM2** (в Selectel Secrets / файлы loader’а):
- `VM2_INTERNAL_TLS_CERTIFICATE` ← содержимое `vm2-processing.crt` (можно fullchain: cert + ca.crt)
- `VM2_INTERNAL_TLS_PRIVATE_KEY``vm2-processing.key`
**VM1**:
scp -i ~/.ssh/hansel ./han-internal-ca/ca.crt root@135.106.164.58:/tmp/ca.crt
На VM1 от root:
install -d -o root -g root -m 0755 /opt/han-chat/backend/secrets/tls
install -o root -g root -m 0644 /tmp/ca.crt \
/opt/han-chat/backend/secrets/tls/processing-internal-ca.crt
rm -f /tmp/ca.crt
ls /opt/han-chat/backend/secrets/tls/processing-internal-ca.crt
nano /opt/han-chat/backend/.env
MESSAGE_SAFETY_URL=https://processing.internal:8443
MESSAGE_SAFETY_CA_HOST_PATH=/opt/han-chat/backend/secrets/tls/processing-internal-ca.crt
# PRIVATE NETWORK
В private DNS / hosts на VM1:
echo '192.168.0.4 processing.internal' >> /etc/hosts
Зашифруйте пароль Selectel service user через systemd credentials, не помещая
его в аргументы или history:
```sh
read -rsp 'Selectel VM2 service-user password: ' SELECTEL_PASSWORD; echo
printf '%s' "$SELECTEL_PASSWORD" | systemd-creds encrypt \
--name=selectel-service-user-password - \
/etc/han/credentials/vm2.selectel-password.cred
unset SELECTEL_PASSWORD
chown root:root /etc/han/credentials/vm2.selectel-password.cred
chmod 0600 /etc/han/credentials/vm2.selectel-password.cred
```
Активные nginx allow-list файлы редактирует только `root`; последней строкой
обязательно остаётся `deny all;`. До cutover Bitrix public allow-list должен
оставаться закрытым:
```sh
editor /opt/han-chat/services/nginx/allowlists/private-caller-allowlist.conf
install -m 0644 -o root -g root \
/opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf.template \
/opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf
chown root:root /opt/han-chat/services/nginx/allowlists/*.conf
chmod 0644 /opt/han-chat/services/nginx/allowlists/*.conf
```
Нужно заполнить **два активных nginx allow-list** на VM2 (не UFW из `setup-vm.sh`). Шаблоны лежат в `nginx/allowlists/`.
### 1. `private-caller-allowlist.conf` — обязательно для работы Safety
Кто может ходить на **private API `:8443`** (VM1 → Message Safety / sync status).
Пример:
```nginx
# allow приватный IP VM1
allow 192.168.0.10/32;
# опционально — ваш VPN/ops в private сети
# allow 192.168.0.50/32;
deny all;
```
Берёте приватный IP VM1 (тот же смысл, что `VM1_PRIVATE_CIDRS`). Последняя строка всегда `deny all;`.
### 2. `bitrix-webhook-allowlist.conf` — только перед включением Bitrix
Кто может бить в **публичные webhook** (`/bitrix/sync/webhook/...`).
До cutover ранбук говорит: **оставить закрытым** (`deny all;` без `allow`) — это нормально, пока `BITRIX_SYNC_ENABLED=false`.
Перед включением sync — IP/CIDR исходящих адресов Битрикс24 (или прокси), например:
```nginx
allow 185.x.x.x/32;
deny all;
```
### Где править
На VM2 после раскладки релиза:
```text
/opt/han-chat/services/nginx/allowlists/private-caller-allowlist.conf
/opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf
```
Шаблоны-подсказки: `*.conf.template`. Активные `.conf` в git намеренно с одним `deny all;` — заполняете на сервере (или копируете из template и раскомментируете `allow`).
Это **отдельный слой от UFW**: UFW режет порт на хосте, nginx allow-list — кто дойдёт до location после TLS.
# Сертификат PostgreSQL и первоначальный выпуск public TLS
### CA управляемой PostgreSQL
Скачайте CA-сертификат кластера из панели провайдера и передайте его на VM2 во
временный путь. Под `root` установите сертификат вне каталога релиза:
scp -i C:\Users\MI\.ssh\han_vm2_deploy -r C:\Users\MI\Documents\job\HAN_new_life\HANapp\Production\sertificates\CA.pem deploy@135.106.179.209:/tmp/ca.pem
```sh
install -d -m 0755 -o root -g root /etc/han/ca
install -m 0644 -o root -g root \
/tmp/ca.pem \
/etc/han/ca/managed-postgresql-ca.pem
openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \
-noout -subject -issuer -dates
rm -f /tmp/ca.pem
```
# Первоначальный выпуск Let's Encrypt
`PROCESSING_PUBLIC_HOST` должен быть DNS-именем, A-запись которого уже указывает
на публичный IP VM2. Сертификат на IP-адрес этим порядком не выпускается. Порт
`80` должен быть разрешён в cloud firewall/UFW и пока не занят nginx.
Под `root` задайте значения только для текущей shell-сессии и подготовьте
постоянный webroot:
```sh
PUBLIC_HOST=service4chat.han0107.ru
ACME_EMAIL=ap@han.ru
install -d -m 0755 -o root -g root /var/lib/han-chat/acme
getent ahostsv4 "$PUBLIC_HOST"
ss -lntp | grep -E ':80[[:space:]]' && {
echo 'Порт 80 уже занят; остановите listener перед standalone-проверкой' >&2
exit 1
} || true
```
Сначала проверьте ACME через staging CA. Этот сертификат nginx не использует:
```sh
certbot certonly --standalone --preferred-challenges http \
--staging \
-d "$PUBLIC_HOST" \
--cert-name "${PUBLIC_HOST}-staging" \
--email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
certbot delete --cert-name "${PUBLIC_HOST}-staging" --non-interactive
```
После успешного staging-теста выпустите production-сертификат:
```sh
certbot certonly --standalone --preferred-challenges http \
-d "$PUBLIC_HOST" \
--cert-name "$PUBLIC_HOST" \
--email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
certbot certificates
test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem"
test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem"
```
Nginx получает `/etc/letsencrypt` с host read-only и после этого может пройти
Gate 4 и первый запуск. Не копируйте private key в каталог релиза.
# Preflight, миграции и первый запуск под `root`
Сначала синхронизируйте секреты. Затем выполните статический preflight:
```sh
systemctl start han-secrets-vm2.service
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose config --quiet
```
для перезапуска секретов
systemctl restart han-secrets-vm2.service
systemctl show han-secrets-vm2.service -p ActiveState -p SubState -p Result -p ExecMainStartTimestamp -p ExecMainStatus
Перед первым `bitrix-sync-migrate` владелец `han_app` или администратор БД
выдаёт Bitrix migration-role временный read-only доступ к legacy mapping:
```sql
GRANT USAGE ON SCHEMA han_app TO bitrix_sync_user;
GRANT SELECT ON TABLE han_app.entity_external_mapping
TO bitrix_sync_user;
До runtime выполните миграции отдельными DB roles и активируйте начальный
Message Safety config:
```sh
/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml --version 1 --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version 1 --approved-by '<APPROVER>'
```
Потом права отзываем
REVOKE SELECT ON TABLE han_app.entity_external_mapping
FROM bitrix_sync_user;
REVOKE USAGE ON SCHEMA han_app FROM bitrix_sync_user;
Первый запуск и enable выполняет `root` только после прохождения gates:
Установка/редактирование unit, Compose, `.env`, secret mapping, credential,
TLS, allow-list и запуск migration jobs остаются операциями `root`.
# GATES
### Gate 1 — секреты материализованы
Под `root` на VM2:
```sh
systemctl restart han-secrets-vm2.service
systemctl is-active han-secrets-vm2.service
journalctl --no-pager -u han-secrets-vm2.service
test -s /run/han-chat/secrets/manifest
cut -d= -f1 /run/han-chat/secrets/manifest | sort
```
Ожидается `active`; журнал не содержит значений секретов; последняя команда
показывает только имена всех ключей из mapping. Не выполняйте `cat` файлов
секретов и не вставляйте реальные значения в terminal history.
### Gate 2 — статический preflight и Compose
Под `root` на VM2:
```sh
cd /opt/han-chat/services
deployment/preflight.sh
/usr/local/sbin/han-vm2-compose config --quiet
/usr/local/sbin/han-vm2-compose config --services
/usr/local/sbin/han-vm2-compose config --images
```
Все команды должны завершиться с кодом `0`. В списке services нет PostgreSQL,
а все production images содержат `@sha256:`. Вывод полного resolved Compose в
файл не сохраняйте.
### Gate 3 — миграции и активный Message Safety config
Команды миграций из предыдущего раздела выполняются под `root`. После них:
```sh
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
message-safety-migrate current
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
bitrix-sync-migrate current
```
Ожидается по одной head revision каждого сервиса. `create --version 1`
выполняется только при первом развёртывании. Для следующего конфига используйте
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
активировать старую версию нельзя. Alembic downgrade запрещён.
### Gate 4 — конфигурация nginx до запуска
После выпуска public TLS в `/etc/letsencrypt` и материализации internal TLS
secrets. В Selectel значения `VM2_INTERNAL_TLS_CERTIFICATE` и
`VM2_INTERNAL_TLS_PRIVATE_KEY` сохраняются как исходный PEM с настоящими
переводами строк, не как повторный base64 и не как строка с литералами `\n`.
После изменения remote secret перезапустите `han-secrets-vm2.service`; preflight
проверит формат PEM и соответствие certificate/key без вывода их содержимого:
```sh
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose run --rm --no-deps \
-e MESSAGE_SAFETY_UPSTREAM_HOST=127.0.0.1 \
-e BITRIX_SYNC_UPSTREAM_HOST=127.0.0.1 \
nginx \
nginx -t -c /etc/nginx/nginx.conf
```
Базовый `nginx.conf` подключает обязательный
`/etc/nginx/conf.d/10-vm2.conf`, поэтому команда завершится ошибкой, если
entrypoint не создал конфигурацию из шаблона. Временные значения upstream
нужны только для проверки до первого запуска backend-контейнеров; production
Compose подставляет DNS-имена сервисов. Ожидается `syntax is ok` и `test is
successful`; ошибок `conf.d is not writable` и предупреждения о превышении
open-file limit быть не должно. Ошибка отсутствующего сертификата является
блокером, а не основанием временно убрать TLS.
### Gate 5 — упорядоченный первый запуск
Под `root` на VM2:
```sh
/usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector
/usr/local/sbin/han-vm2-compose up -d freshclam clamd
/usr/local/sbin/han-vm2-compose up -d \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose up -d \
bitrix-sync bitrix-sync-worker bitrix-sync-reconciliation
/usr/local/sbin/han-vm2-compose up -d nginx
/usr/local/sbin/han-vm2-compose ps
```
Дождитесь `healthy` у сервисов с healthcheck. Не продолжайте при
`Restarting`, `unhealthy`, OOM или неожиданном `Exited`. После успешного
первого запуска передайте дальнейший lifecycle systemd:
```sh
systemctl enable han-secrets-vm2.service han-processing.service
systemctl start han-processing.service
systemctl --no-pager status han-processing.service
```
### Gate 6 — host ports и сертификаты
Под `root` на VM2:
```sh
ss -lntp | grep -E ':(80|443|8443|6379|8080|4317|4318)[[:space:]]'
/usr/local/sbin/han-vm2-compose ps --format json | jq .
```
Ожидаются host listeners только nginx: public `80`, `443` и private-bound
`8443` на `PROCESSING_PRIVATE_BIND_ADDRESS`. `6379`, container `8080` и OTLP
`4317/4318` на host отсутствуют.
С доверенной рабочей станции проверьте public chain:
```sh
openssl s_client -connect service4chat.han0107.ru:443 \
-servername service4chat.han0107.ru \
-verify_hostname service4chat.han0107.ru -verify_return_error </dev/null
```
С VM1 или ops host, имеющего private route, проверьте internal chain и SAN:
```sh
openssl s_client -connect 192.168.0.4:8443 \
-servername processing.internal \
-verify_hostname processing.internal \
-CAfile /opt/han-chat/backend/secrets/tls/processing-internal-ca.crt \
-verify_return_error </dev/null
```
Обе команды должны завершить certificate verification без ошибки.
Переключите renewal с первоначального `standalone` на webroot, который nginx
обслуживает по `/.well-known/acme-challenge/`. `certbot reconfigure` сам
проверит новый способ через staging CA:
```sh
PUBLIC_HOST='service4chat.han0107.ru'
certbot reconfigure \
--cert-name "$PUBLIC_HOST" \
--authenticator webroot \
--webroot-path /var/lib/han-chat/acme
```
Повторный setup после активации релиза устанавливает deploy-hook
`/etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx`: после успешного
обновления он атомарно размещает certificate/key с группой `han-nginx-tls` в
`/var/lib/han-chat/public-tls`, проверяет конфигурацию nginx и отправляет
контейнеру `HUP`.
Проверьте полный цикл и включите штатное расписание Certbot:
```sh
test -x /etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx
certbot renew --dry-run --run-deploy-hooks
systemctl enable --now certbot.timer
systemctl --no-pager status certbot.timer
systemctl list-timers certbot.timer
```
`certbot.timer` проверяет необходимость продления дважды в сутки; сертификат
перевыпускается только при приближении срока. Ошибка dry-run или deploy-hook —
блокер. Итог `Congratulations, all simulated renewals succeeded` означает
успех. Со старым hook сообщение `Hook 'deploy-hook' ran with error output`
могло быть ложным: Compose писал успешный `HUP` как `Killing/Killed`, а
успешный `nginx -t``syntax is ok` в stderr. Исправленный hook выводит
config-test только при ошибке и отправляет HUP без progress-вывода. Порт `80`
после этого остаётся доступен для HTTP-01 renewal.
### Gate 7 — public routing
С внешней тестовой машины:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' \
http://service4chat.han0107.ru/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
https://service4chat.han0107.ru/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
http://service4chat.han0107.ru/bitrix/sync/webhook/contact
curl -sS -o /dev/null -w '%{http_code}\n' \
https://service4chat.han0107.ru/internal/safety/status
curl -sS -o /dev/null -w '%{http_code}\n' -X GET \
https://service4chat.han0107.ru/bitrix/sync/webhook/contact
```
Ожидаемые коды по порядку: `308`, `404`, `426`, `404`, `405`. Для HTTPS
используйте только валидный public certificate, без `-k`.
POST к webhook с адреса вне Bitrix allow-list должен получить `403`; если
cloud firewall настроен на drop, допустим timeout. Затем повторите с
разрешённого source IP и заведомо неверным receiver token: upstream должен
ответить `403`, не `2xx`.
### Gate 8 — private API только с VM1/ops
Следующие команды выполняются **на VM1** или approved ops host, не на VM2.
Используйте private DNS/SAN и внутреннюю CA.
Safety status:
```sh
curl --fail --silent --show-error \
--cacert /opt/han-chat/backend/secrets/tls/processing-internal-ca.crt \
https://processing.internal:8443/internal/safety/status
```
Benign text check через тот же private listener:
```sh
SAFETY_TOKEN="$(cat /run/han-chat/secrets/MESSAGE_SAFETY_SERVICE_TOKEN)"
MESSAGE_ID="$(uuidgen)"
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <<EOF
url = "https://processing.internal:8443/internal/safety/v2/messages/check"
cacert = "/opt/han-chat/backend/secrets/tls/processing-internal-ca.crt"
request = "POST"
header = "X-Service-Token: ${SAFETY_TOKEN}"
header = "Content-Type: application/json"
data = "{\"message_id\":\"${MESSAGE_ID}\",\"content_kind\":\"text\",\"text\":\"VM2 safety canary\",\"attachment\":null}"
EOF
unset SAFETY_TOKEN MESSAGE_ID
```
В standard mode ожидается `HTTP 200`, `verdict=allow` и непустые
`config_version`/`rules_version`. Проверку `202 → Location → task GET`
выполняйте отдельным file smoke только с реальным versioned quarantine object:
выдуманные S3 key/version/ETag не являются валидным тестом.
Для Bitrix status прочитайте token из уже защищённого secret file VM1 в
переменную и передайте curl config через stdin, чтобы значение не попало в
argv/history:
```sh
BITRIX_TOKEN="$(cat /run/han-chat/secrets/BITRIX_SYNC_SERVICE_TOKEN)"
curl --silent --show-error --output /tmp/vm2-sync-status.json \
--write-out '%{http_code}\n' --config - <<EOF
url = "https://processing.internal:8443/internal/sync/v1/status"
cacert = "/opt/han-chat/backend/secrets/tls/processing-internal-ca.crt"
header = "Authorization: Bearer ${BITRIX_TOKEN}"
EOF
unset BITRIX_TOKEN
cat /tmp/vm2-sync-status.json
rm -f /tmp/vm2-sync-status.json
```
При `BITRIX_SYNC_ENABLED=false` ожидается закрытая/неготовая синхронизация, а
не ложный успешный full-mode status. С машины вне `VM1_PRIVATE_CIDRS` и
необязательных приватных/VPN-сетей `OPS_CIDRS` подключение к `8443` должно
завершиться timeout/reject.
### Gate 9 — canary на отсутствие секретов в логах и traces
Создайте **фейковый**, не production token marker и отправьте его с
разрешённого тестового source IP:
CANARY можно ставить любой
```sh
CANARY="HAN_VM2_REDACTION_260813"
curl -sS -o /dev/null \
-H "Authorization: Bearer ${CANARY}" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode "auth[application_token]=${CANARY}" \
"https://service4chat.han0107.ru/bitrix/sync/webhook/contact?token=${CANARY}"
```
На VM2 под `root`:
```sh
CANARY='HAN_VM2_REDACTION_260813'
if /usr/local/sbin/han-vm2-compose logs --no-color \
nginx bitrix-sync message-safety-api otel-collector |
grep -F -- "$CANARY"; then
echo 'FAIL: canary попал в логи' >&2
exit 1
fi
unset CANARY
```
В SigNoz выполните поиск этого же marker по logs и span attributes за окно
теста: результат должен быть пустым. Отдельными фейковыми markers повторите
проверку для DSN-подобной строки, S3 key и object key. Реальные secrets для
такой проверки не используйте.
Не открывайте webhook-трафик, пока `bitrix-sync` отключён. Отключённый или
упавший receiver должен возвращать retryable `503`/закрытую маршрутизацию,
никогда успешный `2xx ignored`.
## Политика отказов
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
контент.
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
capability; ошибка сканирования никогда не превращается в allow.
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
истины.
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
менять вердикты.
- Rollback не понижает схемы, не удаляет durable tasks/mappings и не
запускает `docker compose down -v`.
## Аварийный MOCK
Разрешены только эти пять sudo-команд:
```text
han-message-safety-mode standard
han-message-safety-mode mock --text-free true --file-free true
han-message-safety-mode mock --text-free true --file-free false
han-message-safety-mode mock --text-free false --file-free true
han-message-safety-mode mock --text-free false --file-free false
```
Хелпер атомарно пишет только
`/etc/han-chat/message-safety-mode.env`, пересоздаёт только Safety API,
проверяет health и при сбое восстанавливает предыдущий режим. У MOCK нет
таймаута: держите high-severity alert активным до явного `standard`, затем
проверьте нормальные text/link/file capabilities и EICAR-canary.
## Известные исключения по образам
Образы ClamAV могут потребовать корректировок UID/path после валидации
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
задокументируйте минимальные writable пути для сигнатур/runtime и
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
получает только `freshclam`; `clamd` — нет.
Проверка отправки в Сигноз
docker network ls | grep observability
docker run --rm --network han-processing_observability \
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
traces --otlp-endpoint otel-collector:4317 --otlp-insecure \
--service han-processing-otlp-smoke --traces 100 --rate 20
Проверка
/usr/local/sbin/han-vm2-compose logs --since=5m otel-collector |
grep -Ei 'error|refused|queue is full|Unauthenticated|tls' || echo 'нет ошибок экспорта'
после успешной проверки нужно удалить
docker rmi ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
# Проверка финальная
1. Проверить автозапуск:
```sh
systemctl is-enabled \
docker.service \
han-chat-vm2-docker-firewall.service \
han-secrets-vm2.service \
han-processing.service \
certbot.timer
(все enabled)
systemctl is-active \
docker.service \
han-chat-vm2-docker-firewall.service \
han-processing.service \
certbot.timer
(все active)
/usr/local/sbin/han-vm2-compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}"
```
2. Провести reboot-gate. Только после проверки отдельного входа `admin` и доступа к консоли Selectel:
```sh
systemctl reboot
```
После переподключения повторить команды выше и кратко Gate 68: HTTPS, firewall, private Safety API.
3. Зафиксировать итог релиза:
```sh
/usr/local/sbin/han-vm2-compose config --images
/usr/local/sbin/han-vm2-compose ps
systemctl list-timers certbot.timer
journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service
```
Сохранить версии образов, дату приёмки и результаты gates без значений секретов.
4. Настроить эксплуатационный мониторинг:
- container unhealthy/restart/OOM;
- срок TLS;
- возраст ClamAV signatures;
- OTEL queue/export errors;
- disk/RAM;
- активный MOCK mode;
- недоступность private Safety API.
5. Далее — отдельный controlled cutover Message Safety на VM1: private URL, internal CA, service token, API integration, rollback rehearsal и функциональные проверки.
6. `bitrix-sync` пока оставить:
```dotenv
BITRIX_SYNC_ENABLED=false
BITRIX_SYNC_MODE=disabled
```
Public allow-list — только `deny all;`. Включать Bitrix можно лишь после выполнения gates `module-07`: поля портала, webhooks, migrations, grants, backfill/watermark и rollback rehearsal.
Таким образом, ближайший шаг сейчас — reboot-gate и фиксация приёмки VM2. Затем переход к интеграции VM1, а не немедленное включение Bitrix.
@@ -0,0 +1,317 @@
## Накатывание Notification Center v1
Выполнять на сервере из каталога:
```sh
cd /path/to/HAN_chat_specification/codebase/backend
```
### 1. Подготовить резервную точку
Создайте PITR-маркер/снимок PostgreSQL. Миграция forward-only — откатывать её нельзя.
### 2. Обновить код
```sh
git fetch
git checkout <утверждённый-commit-or-tag>
git status --short
```
Рабочее дерево должно быть чистым.
### 3. Добавить переменные в `.env`
Не перезаписывайте существующий `.env`. Добавьте:
```sh
NOTIFICATIONS_TOKEN_PRODUCER_TEST=<случайный-токен>
NGINX_RATE_LIMIT_NOTIFICATIONS_READ=120r/m
NGINX_RATE_LIMIT_NOTIFICATIONS_ACTION=60r/m
NGINX_RATE_LIMIT_NOTIFICATION_UPLOAD=20r/m
NGINX_RATE_LIMIT_NOTIFICATIONS_PUBLIC=60r/m
```
Токен можно создать так:
```sh
openssl rand -hex 32
chmod 600 .env
```
Проверить конфигурацию:
```sh
./scripts/validate-env .env
docker compose --env-file .env config --quiet
```
### 4. Собрать новые образы
```sh
docker compose --env-file .env build --pull \
api-backend frontend-static nginx
```
### 5. Выполнить миграцию
```sh
PITR_MARKER_CONFIRMED=true ENV_FILE=.env \
deployment/scripts/migrate.sh
deployment/scripts/seed.sh
```
Ожидаемая ревизия:
```text
0008_notifications_v1
```
Проверка:
```sh
docker compose --env-file .env --profile ops run --rm \
migrate-api alembic current
```
### 6. Обновить статический frontend
```sh
docker compose --env-file .env run --rm frontend-static
```
### 7. Перезапустить изменённые сервисы
```sh
docker compose --env-file .env up -d --force-recreate \
api-backend \
delivery-worker \
safety-recovery-worker \
cleanup-worker \
notification-expire-worker \
notification-draft-cleanup-worker \
nginx
```
### 8. Проверить состояние
```sh
docker compose ps
docker compose logs --since=10m \
api-backend \
notification-expire-worker \
notification-draft-cleanup-worker \
nginx
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
```
Проверить API:
```sh
curl -fsS https://chat.han0107.ru/health/live
curl -fsS https://chat.han0107.ru/health/ready
curl -fsS https://chat.han0107.ru/api/v1/public/notification-types
curl -fsS https://chat.han0107.ru/api/v1/public/notifications
```
Внешний internal endpoint обязан возвращать `404`:
```sh
curl -i https://chat.han0107.ru/internal/notifications/v1/notifications
```
### 9. Провести smoke-тест
```sh
deployment/scripts/smoke.sh
```
Дополнительно проверить через `producer_test`:
- Create возвращает `201`;
- повтор того же тела — `200`;
- то же `external_id` с изменённым телом — `409`;
- Cancel — `200`;
- повторный Cancel — `200`;
- уведомление появляется у указанного `user_id`.
Полный эксплуатационный чек-лист находится в `codebase/backend/deployment/RUNBOOK.ru.md`.
Важно: при проблеме не выполнять `docker compose down -v` и не откатывать Alembic. После применения `0008` безопасный путь — исправляющий релиз; старый backend может не пройти readiness из-за проверки версии схемы.
# Создание уведомлений
## Публичные (инсерт в БД)
BEGIN;
INSERT INTO han_app.guest_notifications (
id,
notification_type,
notification_datetime,
header,
text,
priority_override,
date_expired,
price,
old_price,
instruction_url,
chat_message_text,
lifecycle_status,
closed_at,
record_status,
status_changed_at,
status_change_reason,
created_at,
updated_at,
updater_user_id
)
VALUES
-- 1. Авторизация
(
'019fa3e9-8d91-7199-8199-609916d48f04',
'authorize',
now(),
'Войдите в личный кабинет',
'Авторизуйтесь, чтобы видеть персональные статусы, документы и уведомления.',
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
'active',
NULL,
'A',
NULL,
NULL,
now(),
now(),
NULL
),
-- 2. Установка приложения
(
'019fa3e9-8d91-722a-90df-e4824e23b354',
'install_app',
now(),
'Установите приложение HAN',
'Добавьте приложение на главный экран, чтобы сервис всегда был под рукой.',
NULL,
NULL,
NULL,
NULL,
'https://www.han0107.ru/app/how-install-pwa',
NULL,
'active',
NULL,
'A',
NULL,
NULL,
now(),
now(),
NULL
),
-- 3. Глобальная акция
(
'019fa3e9-8d91-7d19-8873-eefb2a60f116',
'promo_global',
now(),
'Специальное предложение',
'Узнайте подробнее об актуальной акции.',
NULL,
now() + interval '30 days',
NULL,
NULL,
NULL,
'Здравствуйте! Хочу узнать подробнее об акции.',
'active',
NULL,
'A',
NULL,
NULL,
now(),
now(),
NULL
),
-- 4. Глобальное предложение
(
'019fa3e9-8d91-7452-a13d-3d2518ea257d',
'ads_global',
now(),
'Нужна помощь?',
'Расскажем об услугах и подберём подходящее решение.',
NULL,
now() + interval '30 days',
NULL,
NULL,
NULL,
'Здравствуйте! Хочу получить консультацию по услугам.',
'active',
NULL,
'A',
NULL,
NULL,
now(),
now(),
NULL
)
ON CONFLICT (id) DO UPDATE SET
notification_datetime = EXCLUDED.notification_datetime,
header = EXCLUDED.header,
text = EXCLUDED.text,
priority_override = EXCLUDED.priority_override,
date_expired = EXCLUDED.date_expired,
price = EXCLUDED.price,
old_price = EXCLUDED.old_price,
instruction_url = EXCLUDED.instruction_url,
chat_message_text = EXCLUDED.chat_message_text,
lifecycle_status = 'active',
closed_at = NULL,
record_status = 'A',
status_changed_at = now(),
status_change_reason = 'guest_campaign_republished',
updated_at = now();
COMMIT;
## Персональные
Запускать из /opt/han-chat/backend. Публичный nginx не пропускает internal API, поэтому используем контейнер в backend-сети.
read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST: " TOKEN
echo
read -rp "USER_ID клиента: " USER_ID
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
EXTERNAL_ID="manual-news-$(date +%s)"
docker run --rm -i \
--network han-chat-backend \
curlimages/curl:latest \
-sS -i \
-X POST \
'http://api-backend:8000/internal/notifications/v1/notifications' \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
--data-binary @- <<JSON
{
"user_id": "${USER_ID}",
"notification_type": "news",
"source": "producer_test",
"external_id": "${EXTERNAL_ID}",
"notification_datetime": "${NOW}",
"header": "Тестовое уведомление",
"text": "Уведомление создано через Internal API",
"details": {
"details_header": "Проверка Notification Center",
"details_text": "Это тестовое уведомление от producer_test."
}
}
JSON