# #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:@:/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080 SMS_SERVICE_TOKEN= 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:///callbacks/idgtl/sms IDGTL_SMS_CALLBACK_USERNAME= IDGTL_SMS_CALLBACK_PASSWORD= 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(''::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` автоматически не переотправлять — их необходимо разбирать вручную.