Реализована интеграция с СМС провайдером

This commit is contained in:
mi
2026-07-23 11:49:15 +03:00
parent cc0163eb94
commit b1ed714d5b
89 changed files with 5934 additions and 202 deletions
+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` автоматически не переотправлять — их необходимо разбирать вручную.