Проект разделен на два репозитория
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# Закрыто 21.07-27.07
|
||||
## MVP frontend: один чат с компанией без истории диалогов; пункт «Чат» открывает текущий активный диалог или создаёт его при отсутствии
|
||||
## При создании пользователя номер телефона копировать в профиль Russian_Phone
|
||||
## Убрать хеширование устройства клиента в devise_json - хочу видеть его параметры.
|
||||
## Добавить параметр, ограничивающих кол-во неуспешных попыток ввода смс.
|
||||
## Поправить чтение сообщений от Битрикса. Сейчас они выглядят так: "[b]Антон Пичугин:[/b] [br]опять ты?" Надо убрать из текста сообщения Отправителя в битриксе
|
||||
## Если неавторизованный пользователь вводит сообщение, после отправки идет на регистрацию, после окончания регистрации его сообщение пропадает. Надо чтобы сохранялось и отправлялось (по аналогии с нажатием на кнопку из раздела "Популярные вопросы")
|
||||
## Изменение в БД по аудитам (заполнение IP, сквозное заполнение UserSession)
|
||||
## Яндекс.капчу добавить
|
||||
## Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама)
|
||||
## При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."
|
||||
## Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP.
|
||||
## Моделирование уведомлений — постановка v6 синхронизирована с `functional_blocks (business logic)/notification-requirements.md`, arch-00…05 и module-01/03/07. Зафиксированы: instruction только в новой вкладке; TTL только при отсутствии `date_expired`; первое скачивание любого связанного документа скрывает; `producer_test` только для smoke, secret/hash раздельно.
|
||||
## Кнопка "Позвонить оператору" (ссылка tel:+74999591007)
|
||||
## На главном экране две кнопки: чат и звонок оператору. На кнопке с чатом уведомление при наличии непрочитанных сообщений.
|
||||
|
||||
# Закрыто 28.07-03.08
|
||||
## #MONITORING Подключить OTLP-провайдер (Signoz)
|
||||
## #UI Отправлять на UI информацию разные ошибки при попытках авторизации в зависимости от события: код неверен, истёк или уже использован; превышен лимит попыток авторизации, попробуйте через 24 часа (в случаях превышения otp.phone.max_send_attempts_per_24h); превышен лимит неуспешных авторизаций, начните процедуру заново (в случае превышения otp.phone.max_verify_attempts).
|
||||
## #UI При отрицательном результате проверки сообщения через message-safety, если сообщение отправлялось с главного экрана, то пользователь не переводится в чат, ему под окном главного экрана выпадает сообщение об ошибке. Не на всех устройствах это видно. Воспринимается как UX-дефект. Как надо: вне зависимости от решения message-safety, если пользователь отправил сообщение, то он переводится на экран с чатом. Далее, сейчас отрицательный результат message-safety выводится пользователю как техническая ошибка (красным цветом под полем ввода сообщения) и опять же воспринимается не как бизнес-логика, а как техническая ошибка. Это поведение нужно поменять. Если сообщение пользователя не прошло проверку, нужно ему в окне чата прислать ответ: Для сообщений: К сожалению, ваше сообщение не соответствует правилам данного чата и не может быть отправлено. Попробуйте переформулировать. Для документов: К сожалению, ваш документ не прошел проверку и не может быть доставлен.
|
||||
## UX-дефект: frontend показывает «Не удалось завершить вход» при ошибке отправки отложенного сообщения, хотя вход завершён. Это следует исправить: завершать экран авторизации после bootstrap, а ошибку Bitrix показывать уже в чате (если сообщение отклонено сервисом message-safety, учесть реализацию предыдущего пункта)
|
||||
## #UI Ограничить кол-во символов в сообщении на фронте. Показывать в моменте счетчик: n/max, где n сколько символов уже напечатано, max сколько может быть отправлено. Максимальное кол-во символов - положить в app_settings.
|
||||
## #UI Убрать с экрана ввода номера телефона тексты согласий внизу экрана: Нажимая «Получить код», вы соглашаетесь с условиями использования и политикой конфиденциальности. Согласия пользователь дает ранее на отдельном экране.
|
||||
## #BACK_SECURE Перенести секреты из .env в KM Selectel.
|
||||
## #BACK_SECURE Провести аудит безопасности вм
|
||||
## #UI Унифицированы гостевые экраны Центра уведомлений, Профиля и Чата: единый стиль сообщения о необходимости входа и кнопка «Авторизоваться».
|
||||
## #BACK_BUSINESS Архитектурное решение принято: `message-safety` и `bitrix-sync` выносятся на самостоятельную ВМ2 с одним root Compose/nginx; Message Safety доступен privately, CRM webhook приходит напрямую на отдельный public host ВМ2; реализация/cutover остаются в задачах 16–17.
|
||||
## #BACK_SECURE Разработан архитектурный стандарт по безопасному деплою и размещению сервисов на ВМ.
|
||||
|
||||
# Закрыто 04.08-10.08
|
||||
## #BACK_DEFECT Исправлены дублирующиеся триггеры на создание контакта для сервиса синхронизации. Исправлено создание в БД лишних задач на обновление контакта (каждый бустрап пользователя вызывал задачу на обновление контакта)
|
||||
|
||||
# В разработку:
|
||||
|
||||
1 #BACK_SECURE После интеграции с смс провайдером, реализовать debounce механизм при авторизации - каждая след. смс можно отправить через все большее окно. (сейчас есть Фиксированный cooldownmin_seconds_between_attempts)
|
||||
2. #MONITORING Настроить мониторинг в Signoz
|
||||
3. #BACK_BUSINESS Хранить историю устройств, с которых пользователь входил в ЛК (Ид юзера, идентификатор устройства, дата последнего входа, способ входа - веб\приложение)
|
||||
4. #BACK_BUSINESS Веб-пуши для PWA
|
||||
5. #UI На кнопке Чат отображать значок наличия непрочитанных уведомлений. Требуется синхронизация между устройствами (решение, например через Dialog.client_last_opened_at)
|
||||
6. #BACK_BUSINESS Описание бизнес сущностей: Пользователь
|
||||
7. #BACK_BUSINESS Описание бизнес сущностей: Сообщение
|
||||
8. #UI Сделать страницу с инструкцией по установке приложения
|
||||
9. #LEGAL Написать пользовательское соглашение.
|
||||
10. #BACK_BUSINESS Разработка Message Safety v2 по [`module-05`](modules/module-05-message-safety.md), §18 DoR/DoD и cutover gates [`module-10-vm2`](modules/module-10-deployment-vm2.md):
|
||||
- API/OpenAPI v2, versioned `message_safety.config_versions`, configuration activation/validation и schema migrations;
|
||||
- PostgreSQL queue/lease/fencing/deadline + Redis hot cache/rate/wakeup;
|
||||
- Unicode normalization и versioned text rule bundle/corpus;
|
||||
- local-only URL parser/IDNA/DNS/IP policy и cache split;
|
||||
- immutable S3 version flow, file detectors и technical matrix;
|
||||
- ClamAV/freshclam, signature rollback и EICAR tests;
|
||||
- api-backend integration: `202` polling, M8, `safety.chat.blocked`, conditional promote;
|
||||
- VM2 internal nginx/TLS/egress/collector/dashboards + root-owned emergency MOCK helper/alert;
|
||||
- contract/security/failure/load acceptance и S3 negative gate;
|
||||
- controlled v1→v2 cutover, rollback rehearsal и удаление stub references.
|
||||
11. #BACK_BUSINESS Разработка sync-service
|
||||
12. #INFRASTRUCTURE Перераскатить сервисы от деплоя
|
||||
13. #INFRASTRUCTURE Поднять второй контур для продакшн
|
||||
14. #INFRASTRUCTURE Спрятать сеть за балансировщиком нагрузки
|
||||
15. #BACK_DEFECT Автопродление TLS падает при перезагрузке nginx; сертификат действует до 14.10.2026. (Исправить reload внутри контейнера и проверить systemctl start an-chat-ssl-renew.service до успешного завершения.)
|
||||
16. #INFRASTRUCTURE WireGuard-only SSH.
|
||||
17. #LEGAL Обновить документы по ПД - модель угроз и меры защиты.
|
||||
18. #LEGAL Уведомление в РКН по БД обработки ПД.
|
||||
19. #MONITORING Добавить логи (Для Python-сервисов добавить OTLP Log Exporter: api-backend; sms-service; sms-worker. Подключить LoggerProvider, BatchLogRecordProcessor и bounded queue. Передавать resource attributes: service.name; service.version; deployment.environment; service.namespace=han-chat.) Экспортировать структурированные поля request_id, trace_id, span_id, severity и event name. Оставить stdout как аварийный локальный журнал. Добавить canary-тесты, запрещающие экспорт токенов, cookie, телефонов, email, текстов сообщений, SQL и object keys.).
|
||||
20. #MONITORING Nginx metrics/tracing в signoz
|
||||
21. #BACK_SECURE Сформулировать требования для обработки персональных данных
|
||||
22. #UI Скрыть раздел диагностики в профиле пользователя (наличие этого раздела в енв передать, как часть наследования продуктовой среды?)
|
||||
23. #BACK_BUSINESS Разработка notification-service
|
||||
24. #BACK_BUSINESS Store-review вход: точечный bypass в Keycloak OTP SPI по номеру из `.env` (`STORE_REVIEW_ENABLED` / `STORE_REVIEW_PHONE` / `STORE_REVIEW_OTP`) — для этого телефона SMS не шлётся, verify принимает фиксированный OTP; остальные номера идут обычным OTP/SMS. Не путать с глобальным `KEYCLOAK_OTP_MOCK_*`. Учётные данные только в Review Notes стора (не в бинарнике/UI); пользователь с демо-контентом; в production включать только на время ревью.
|
||||
25. #UI Реализация мнемоник: Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?)
|
||||
26. #INFRASTRUCTURE Развернуть Гит в облаке
|
||||
27. #BACK_BUSINESS Синхронизация документов из битрикс24 в Приложение.
|
||||
28. #INFRASTRUCTURE Зарегистрировать Conteiner registry Selectel
|
||||
29. #BACK_BUSINESS Определить пул тестовых номеров, чтобы их было легко в Б24 отслеживать.
|
||||
30. #INFRASTRUCTURE перевести взаимодействие с signoz на TLS (сейчас OTEL_REMOTE_TLS_INSECURE=true)
|
||||
|
||||
# Критично для релиза:
|
||||
1. Разработка message-safety
|
||||
2. Разработка sync-service
|
||||
3. Пользовательское соглашение
|
||||
~~4. Подключить OTLP-провайдер~~
|
||||
~~5. Починить UI баги~~
|
||||
6. Второй контур для продакшн
|
||||
@@ -0,0 +1,42 @@
|
||||
# Логи по контейнерам
|
||||
docker compose --env-file .env logs --tail 100 keycloak
|
||||
docker compose --env-file .env logs --tail 100 api-backend
|
||||
docker compose --env-file .env logs --tail 100 keycloak
|
||||
|
||||
# Пересобрать образы и поднять всё заново
|
||||
Из каталога проекта на ВМ:
|
||||
cd /opt/han-chat/backend
|
||||
1. Собрать все локальные образы (как в DEPLOYMENT_GUIDE)
|
||||
docker compose --env-file .env build --pull
|
||||
2. Поднять весь контур
|
||||
docker compose --env-file .env up -d
|
||||
Или одной командой:
|
||||
cd /opt/han-chat/backend
|
||||
docker compose --env-file .env up -d --build
|
||||
|
||||
# Только пересоздать контейнеры (без пересборки образов)
|
||||
Если меняли только .env (например, IP БД), образы пересобирать не нужно:
|
||||
cd /opt/han-chat/backend
|
||||
docker compose --env-file .env up -d --force-recreate
|
||||
|
||||
# Полная пересборка + пересоздание
|
||||
cd /opt/han-chat/backend
|
||||
docker compose --env-file .env up -d --build --force-recreate
|
||||
Проверка
|
||||
docker compose --env-file .env ps
|
||||
|
||||
# Изменение маппинга пользователя приложения на CRM (Проект)
|
||||
bitrix_sync.request_bitrix_contact_rebind(
|
||||
user_id,
|
||||
target_b24_id,
|
||||
reason,
|
||||
operator_id
|
||||
)
|
||||
|
||||
# Копирование проекта на ВМ
|
||||
|
||||
|
||||
|
||||
|
||||
# Копирование отдельного файла на ВМ
|
||||
|
||||
@@ -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.
|
||||
@@ -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/HAN_chat_specification/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/HAN_chat_specification/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 --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.1
|
||||
|
||||
# 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 6–8: 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
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# diagnose-han-chat.sh — запускать из /opt/han-chat/backend
|
||||
set -euo pipefail
|
||||
cd /opt/han-chat/backend
|
||||
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" "$@"; }
|
||||
|
||||
echo "=== 1. Статус сервисов ==="
|
||||
compose ps api-backend keycloak redis message-safety
|
||||
|
||||
echo
|
||||
echo "=== 2. Healthcheck api-backend (что именно падает) ==="
|
||||
compose exec -T api-backend \
|
||||
python -c "
|
||||
import json, urllib.request
|
||||
try:
|
||||
r = urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=5)
|
||||
print('HTTP', r.status)
|
||||
print(json.dumps(json.loads(r.read()), indent=2, ensure_ascii=False))
|
||||
except urllib.error.HTTPError as e:
|
||||
print('HTTP', e.code)
|
||||
print(json.dumps(json.loads(e.read()), indent=2, ensure_ascii=False))
|
||||
"
|
||||
|
||||
echo
|
||||
echo "=== 3. Keycloak health (management port 9000) ==="
|
||||
compose exec -T keycloak bash -c \
|
||||
"exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /auth/health/ready HTTP/1.0\r\n\r\n' >&3 && cat <&3" \
|
||||
2>/dev/null | head -20 || echo "Keycloak health FAILED"
|
||||
|
||||
echo
|
||||
echo "=== 4. api-backend -> Keycloak (JWKS) ==="
|
||||
compose exec -T api-backend \
|
||||
python -c "
|
||||
import os, urllib.request, json
|
||||
base = os.environ.get('KEYCLOAK_INTERNAL_URL','http://keycloak:8080/auth').rstrip('/')
|
||||
realm = os.environ.get('KEYCLOAK_REALM','han-chat')
|
||||
url = f'{base}/realms/{realm}/.well-known/openid-configuration'
|
||||
try:
|
||||
d = json.loads(urllib.request.urlopen(url, timeout=5).read())
|
||||
print('discovery OK, jwks_uri:', d.get('jwks_uri'))
|
||||
except Exception as e:
|
||||
print('discovery FAILED:', e)
|
||||
"
|
||||
|
||||
echo
|
||||
echo "=== 5. Счётчик рестартов ==="
|
||||
docker inspect --format '{{.Name}} restarts={{.RestartCount}} started={{.State.StartedAt}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}n/a{{end}}' \
|
||||
han-chat-api-backend-1 han-chat-keycloak-1
|
||||
|
||||
echo
|
||||
echo "=== 6. Последние ошибки в логах ==="
|
||||
echo "--- api-backend ---"
|
||||
docker logs --tail 80 han-chat-api-backend-1 2>&1 | grep -iE 'error|failed|exception|jwks|settings|postgres' || echo "(нет совпадений)"
|
||||
echo "--- keycloak ---"
|
||||
docker logs --tail 80 han-chat-keycloak-1 2>&1 | grep -iE 'error|failed|exception|FATAL|OutOfMemory' || echo "(нет совпадений)"
|
||||
|
||||
echo
|
||||
echo "=== 7. Память (Keycloak часто падает от OOM) ==="
|
||||
free -h
|
||||
docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}' \
|
||||
han-chat-keycloak-1 han-chat-api-backend-1 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "=== 8. Exited контейнеры (в метриках было exited=2) ==="
|
||||
docker ps -a --filter status=exited --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'
|
||||
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env bash
|
||||
# HAN Chat VM metrics snapshot
|
||||
# Host: Ubuntu 24.04 + Docker + UFW + fail2ban
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./han-vm-metrics.sh # human report to stdout
|
||||
# sudo ./han-vm-metrics.sh --json # JSON to stdout
|
||||
# sudo ./han-vm-metrics.sh --alert # exit 1 if thresholds breached
|
||||
# sudo ./han-vm-metrics.sh --log /var/log/han-vm-metrics.log
|
||||
#
|
||||
# Cron example (every 5 min):
|
||||
# */5 * * * * root /opt/han-chat/ops/han-vm-metrics.sh --alert --log /var/log/han-vm-metrics.log
|
||||
#
|
||||
# Disk notes:
|
||||
# Checks both / and /var/lib/docker. If Docker Root Dir is on a separate
|
||||
# mount, its fill level is reported and alerted independently.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODE="text"
|
||||
LOG_FILE=""
|
||||
ALERT=0
|
||||
DISK_WARN_PCT="${DISK_WARN_PCT:-80}"
|
||||
DISK_CRIT_PCT="${DISK_CRIT_PCT:-90}"
|
||||
LOAD_WARN_MULT="${LOAD_WARN_MULT:-1.5}" # vs CPU count
|
||||
MEM_WARN_PCT="${MEM_WARN_PCT:-90}"
|
||||
CERT_WARN_DAYS="${CERT_WARN_DAYS:-21}"
|
||||
SSH_FAIL_WARN="${SSH_FAIL_WARN:-50}" # failed attempts last hour
|
||||
DOMAIN="${METRICS_DOMAIN:-chat.han0107.ru}"
|
||||
DOCKER_ROOT="${DOCKER_ROOT:-/var/lib/docker}"
|
||||
|
||||
require_value() {
|
||||
local opt="$1" val="${2:-}"
|
||||
if [[ -z "$val" || "$val" == -* ]]; then
|
||||
echo "Error: $opt requires a value" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json) MODE="json"; shift ;;
|
||||
--alert) ALERT=1; shift ;;
|
||||
--log)
|
||||
require_value "$1" "${2:-}"
|
||||
LOG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--domain)
|
||||
require_value "$1" "${2:-}"
|
||||
DOMAIN="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
sed -n '2,18p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ts_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
hostname_f="$(hostname -f 2>/dev/null || hostname)"
|
||||
kernel="$(uname -r)"
|
||||
uptime_s="$(cut -d. -f1 /proc/uptime)"
|
||||
cpu_n="$(nproc)"
|
||||
loadavg="$(cut -d' ' -f1-3 /proc/loadavg)"
|
||||
load1="$(cut -d' ' -f1 /proc/loadavg)"
|
||||
|
||||
# --- memory ---
|
||||
mem_total_kb="$(awk '/MemTotal:/ {print $2}' /proc/meminfo)"
|
||||
mem_avail_kb="$(awk '/MemAvailable:/ {print $2}' /proc/meminfo)"
|
||||
mem_used_kb=$((mem_total_kb - mem_avail_kb))
|
||||
mem_used_pct=$((mem_used_kb * 100 / mem_total_kb))
|
||||
swap_total_kb="$(awk '/SwapTotal:/ {print $2}' /proc/meminfo)"
|
||||
swap_free_kb="$(awk '/SwapFree:/ {print $2}' /proc/meminfo)"
|
||||
swap_used_kb=$((swap_total_kb - swap_free_kb))
|
||||
|
||||
# --- disk helper: path -> total_k used_k avail_k used_pct ---
|
||||
read_df() {
|
||||
local path="$1"
|
||||
local line total used avail pct_raw
|
||||
line="$(df -P "$path" 2>/dev/null | awk 'NR==2 {print $2,$3,$4,$5}')"
|
||||
if [[ -z "$line" ]]; then
|
||||
echo "0 0 0 0"
|
||||
return
|
||||
fi
|
||||
read -r total used avail pct_raw <<<"$line"
|
||||
echo "$total $used $avail ${pct_raw%%%}"
|
||||
}
|
||||
|
||||
# --- disk root ---
|
||||
read -r disk_total_k disk_used_k disk_avail_k disk_used_pct <<<"$(read_df /)"
|
||||
|
||||
# --- disk Docker root (may be same FS as / or a separate mount) ---
|
||||
docker_disk_same_as_root=1
|
||||
docker_disk_total_k=0
|
||||
docker_disk_used_k=0
|
||||
docker_disk_avail_k=0
|
||||
docker_disk_used_pct=0
|
||||
if [[ -d "$DOCKER_ROOT" ]]; then
|
||||
read -r docker_disk_total_k docker_disk_used_k docker_disk_avail_k docker_disk_used_pct <<<"$(read_df "$DOCKER_ROOT")"
|
||||
root_src="$(findmnt -n -o SOURCE / 2>/dev/null || true)"
|
||||
docker_src="$(findmnt -n -o SOURCE --target "$DOCKER_ROOT" 2>/dev/null || true)"
|
||||
if [[ -n "$root_src" && -n "$docker_src" && "$root_src" != "$docker_src" ]]; then
|
||||
docker_disk_same_as_root=0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- reboot / updates (lightweight; avoid apt list --upgradable) ---
|
||||
reboot_required=0
|
||||
[[ -f /var/run/reboot-required ]] && reboot_required=1
|
||||
pending_pkgs=0
|
||||
pending_security=0
|
||||
if [[ -x /usr/lib/update-notifier/apt-check ]]; then
|
||||
# stderr: "<total>;<security>"
|
||||
apt_check_out="$(/usr/lib/update-notifier/apt-check 2>&1 || true)"
|
||||
pending_pkgs="${apt_check_out%%;*}"
|
||||
pending_security="${apt_check_out##*;}"
|
||||
elif [[ -f /var/lib/update-notifier/updates-available ]]; then
|
||||
pending_pkgs="$(grep -oE '^[0-9]+' /var/lib/update-notifier/updates-available 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
[[ "$pending_pkgs" =~ ^[0-9]+$ ]] || pending_pkgs=0
|
||||
[[ "$pending_security" =~ ^[0-9]+$ ]] || pending_security=0
|
||||
|
||||
# --- firewall ---
|
||||
ufw_status="unknown"
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw_status="$(ufw status 2>/dev/null | awk 'NR==1 {print tolower($2)}')"
|
||||
fi
|
||||
|
||||
# --- fail2ban ---
|
||||
f2b_active="inactive"
|
||||
f2b_jails=0
|
||||
f2b_banned_now=0
|
||||
f2b_banned_total=0
|
||||
f2b_failed_total=0
|
||||
if command -v fail2ban-client >/dev/null 2>&1; then
|
||||
if systemctl is-active --quiet fail2ban 2>/dev/null; then
|
||||
f2b_active="active"
|
||||
f2b_status="$(fail2ban-client status 2>/dev/null || true)"
|
||||
f2b_jails="$(echo "$f2b_status" | awk -F: '/Jail list/ {gsub(/ /,"",$2); n=split($2,a,","); print n; exit}')"
|
||||
[[ -z "$f2b_jails" ]] && f2b_jails=0
|
||||
if fail2ban-client status sshd >/dev/null 2>&1; then
|
||||
sshd_st="$(fail2ban-client status sshd 2>/dev/null || true)"
|
||||
f2b_banned_now="$(echo "$sshd_st" | awk -F: '/Currently banned/ {gsub(/ /,"",$2); print $2; exit}')"
|
||||
f2b_banned_total="$(echo "$sshd_st" | awk -F: '/Total banned/ {gsub(/ /,"",$2); print $2; exit}')"
|
||||
f2b_failed_total="$(echo "$sshd_st" | awk -F: '/Total failed/ {gsub(/ /,"",$2); print $2; exit}')"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
f2b_banned_now="${f2b_banned_now:-0}"
|
||||
f2b_banned_total="${f2b_banned_total:-0}"
|
||||
f2b_failed_total="${f2b_failed_total:-0}"
|
||||
|
||||
# --- SSH auth last hour (single journalctl pass) ---
|
||||
ssh_failed_1h=0
|
||||
ssh_invalid_1h=0
|
||||
ssh_accepted_1h=0
|
||||
if command -v journalctl >/dev/null 2>&1; then
|
||||
ssh_journal="$(journalctl -u ssh --since '1 hour ago' --no-pager 2>/dev/null || true)"
|
||||
ssh_failed_1h="$(grep -cE 'Failed password|Failed publickey|Connection closed by authenticating' <<<"$ssh_journal" || true)"
|
||||
ssh_invalid_1h="$(grep -c 'Invalid user' <<<"$ssh_journal" || true)"
|
||||
ssh_accepted_1h="$(grep -c 'Accepted' <<<"$ssh_journal" || true)"
|
||||
fi
|
||||
|
||||
# --- listening public ports ---
|
||||
listen_ports="$(ss -tuln 2>/dev/null | awk '/LISTEN/ && ($5 ~ /\*:|:0\.0\.0\.0:|\[::\]:/) {
|
||||
split($5,a,":"); print a[length(a)]
|
||||
}' | sort -n | uniq | tr '\n' ',' | sed 's/,$//')"
|
||||
|
||||
# --- docker ---
|
||||
docker_running=0
|
||||
docker_unhealthy=0
|
||||
docker_exited=0
|
||||
docker_names_unhealthy=""
|
||||
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||
docker_running="$(docker ps -q 2>/dev/null | wc -l | tr -d ' ')"
|
||||
docker_exited="$(docker ps -aq -f status=exited 2>/dev/null | wc -l | tr -d ' ')"
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
name="${line%% *}"
|
||||
health="${line##* }"
|
||||
if [[ "$health" == "unhealthy" ]]; then
|
||||
docker_unhealthy=$((docker_unhealthy + 1))
|
||||
docker_names_unhealthy+="${name},"
|
||||
fi
|
||||
done < <(docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null | awk '{
|
||||
h="unknown"
|
||||
if ($0 ~ /unhealthy/) h="unhealthy"
|
||||
else if ($0 ~ /\(healthy\)/) h="healthy"
|
||||
else if ($0 ~ /Up/) h="up"
|
||||
print $1, h
|
||||
}')
|
||||
docker_names_unhealthy="${docker_names_unhealthy%,}"
|
||||
fi
|
||||
|
||||
# --- TLS cert days left (local nginx :443) ---
|
||||
cert_days=-1
|
||||
cert_cn=""
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
cert_pem="$(echo | openssl s_client -connect 127.0.0.1:443 -servername "$DOMAIN" 2>/dev/null | openssl x509 2>/dev/null || true)"
|
||||
if [[ -n "$cert_pem" ]]; then
|
||||
cert_cn="$(echo "$cert_pem" | openssl x509 -noout -subject 2>/dev/null | sed 's/.*CN *= *//')"
|
||||
end_date="$(echo "$cert_pem" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)"
|
||||
end_epoch="$(date -d "$end_date" +%s 2>/dev/null || true)"
|
||||
now_epoch="$(date +%s)"
|
||||
if [[ -n "${end_epoch:-}" ]]; then
|
||||
cert_days=$(( (end_epoch - now_epoch) / 86400 ))
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- alerts ---
|
||||
alerts=()
|
||||
load_warn="$(awk -v l="$load1" -v n="$cpu_n" -v m="$LOAD_WARN_MULT" 'BEGIN { print (l > n*m) ? 1 : 0 }')"
|
||||
[[ "$load_warn" == "1" ]] && alerts+=("high_load:${load1}>${cpu_n}*${LOAD_WARN_MULT}")
|
||||
[[ "$mem_used_pct" -ge "$MEM_WARN_PCT" ]] && alerts+=("high_memory:${mem_used_pct}%")
|
||||
[[ "$disk_used_pct" -ge "$DISK_CRIT_PCT" ]] && alerts+=("disk_critical:${disk_used_pct}%")
|
||||
[[ "$disk_used_pct" -ge "$DISK_WARN_PCT" && "$disk_used_pct" -lt "$DISK_CRIT_PCT" ]] && alerts+=("disk_warn:${disk_used_pct}%")
|
||||
# Separate Docker mount: independent thresholds (avoid duplicate alerts when same FS)
|
||||
if [[ "$docker_disk_same_as_root" -eq 0 ]]; then
|
||||
[[ "$docker_disk_used_pct" -ge "$DISK_CRIT_PCT" ]] && alerts+=("docker_disk_critical:${docker_disk_used_pct}%")
|
||||
[[ "$docker_disk_used_pct" -ge "$DISK_WARN_PCT" && "$docker_disk_used_pct" -lt "$DISK_CRIT_PCT" ]] && alerts+=("docker_disk_warn:${docker_disk_used_pct}%")
|
||||
fi
|
||||
[[ "$reboot_required" -eq 1 ]] && alerts+=("reboot_required")
|
||||
[[ "$docker_unhealthy" -gt 0 ]] && alerts+=("docker_unhealthy:${docker_names_unhealthy}")
|
||||
[[ "$cert_days" -ge 0 && "$cert_days" -le "$CERT_WARN_DAYS" ]] && alerts+=("cert_expiring:${cert_days}d")
|
||||
[[ "$ssh_failed_1h" -ge "$SSH_FAIL_WARN" ]] && alerts+=("ssh_bruteforce_1h:${ssh_failed_1h}")
|
||||
[[ "$f2b_active" != "active" ]] && alerts+=("fail2ban_inactive")
|
||||
[[ "$ufw_status" != "active" ]] && alerts+=("ufw_inactive")
|
||||
|
||||
alert_count=${#alerts[@]}
|
||||
alert_joined="$(IFS=','; echo "${alerts[*]:-}")"
|
||||
|
||||
# KiB -> MiB (integer; avoids spawning awk)
|
||||
mb() { echo "$((${1:-0} / 1024))"; }
|
||||
|
||||
render_text() {
|
||||
local docker_disk_note="same_fs_as_root"
|
||||
[[ "$docker_disk_same_as_root" -eq 0 ]] && docker_disk_note="separate_mount"
|
||||
cat <<EOF
|
||||
=== HAN VM metrics @ ${ts_utc} ===
|
||||
host: ${hostname_f}
|
||||
kernel: ${kernel}
|
||||
uptime_sec: ${uptime_s}
|
||||
cpu_cores: ${cpu_n}
|
||||
loadavg: ${loadavg}
|
||||
memory: ${mem_used_pct}% used ($(mb "$mem_used_kb")/$(mb "$mem_total_kb") MiB), avail $(mb "$mem_avail_kb") MiB
|
||||
swap: $(mb "$swap_used_kb")/$(mb "$swap_total_kb") MiB
|
||||
disk_root: ${disk_used_pct}% used ($(mb "$disk_used_k")/$(mb "$disk_total_k") MiB), avail $(mb "$disk_avail_k") MiB
|
||||
disk_docker: ${docker_disk_used_pct}% used ($(mb "$docker_disk_used_k")/$(mb "$docker_disk_total_k") MiB), avail $(mb "$docker_disk_avail_k") MiB [${docker_disk_note}: ${DOCKER_ROOT}]
|
||||
reboot_needed: ${reboot_required}
|
||||
apt_upgradable: ${pending_pkgs} (security=${pending_security})
|
||||
ufw: ${ufw_status}
|
||||
listen_ports: ${listen_ports:-none}
|
||||
fail2ban: ${f2b_active}, jails=${f2b_jails}, banned_now=${f2b_banned_now}, banned_total=${f2b_banned_total}, failed_total=${f2b_failed_total}
|
||||
ssh_1h: failed=${ssh_failed_1h}, invalid_user=${ssh_invalid_1h}, accepted=${ssh_accepted_1h}
|
||||
docker: running=${docker_running}, exited=${docker_exited}, unhealthy=${docker_unhealthy}${docker_names_unhealthy:+ ($docker_names_unhealthy)}
|
||||
tls: domain=${DOMAIN}, cn=${cert_cn:-n/a}, days_left=${cert_days}
|
||||
alerts(${alert_count}): ${alert_joined:-none}
|
||||
EOF
|
||||
}
|
||||
|
||||
render_json() {
|
||||
python3 - "$ts_utc" <<'PY' || true
|
||||
import json, os, sys
|
||||
ts = sys.argv[1]
|
||||
data = {
|
||||
"ts": ts,
|
||||
"host": os.environ["M_HOST"],
|
||||
"kernel": os.environ["M_KERNEL"],
|
||||
"uptime_sec": int(os.environ["M_UPTIME"]),
|
||||
"cpu_cores": int(os.environ["M_CPU"]),
|
||||
"loadavg": os.environ["M_LOADAVG"],
|
||||
"load1": float(os.environ["M_LOAD1"]),
|
||||
"memory_used_pct": int(os.environ["M_MEM_PCT"]),
|
||||
"disk_root_used_pct": int(os.environ["M_DISK_PCT"]),
|
||||
"disk_docker": {
|
||||
"path": os.environ["M_D_DISK_PATH"],
|
||||
"used_pct": int(os.environ["M_D_DISK_PCT"]),
|
||||
"same_fs_as_root": os.environ["M_D_DISK_SAME"] == "1",
|
||||
},
|
||||
"reboot_required": os.environ["M_REBOOT"] == "1",
|
||||
"apt_upgradable": int(os.environ["M_APT"]),
|
||||
"apt_security": int(os.environ["M_APT_SEC"]),
|
||||
"ufw": os.environ["M_UFW"],
|
||||
"listen_ports": [p for p in os.environ.get("M_PORTS","").split(",") if p],
|
||||
"fail2ban": {
|
||||
"active": os.environ["M_F2B"] == "active",
|
||||
"jails": int(os.environ["M_F2B_JAILS"]),
|
||||
"banned_now": int(os.environ["M_F2B_NOW"]),
|
||||
"banned_total": int(os.environ["M_F2B_TOT"]),
|
||||
"failed_total": int(os.environ["M_F2B_FAIL"]),
|
||||
},
|
||||
"ssh_1h": {
|
||||
"failed": int(os.environ["M_SSH_FAIL"]),
|
||||
"invalid_user": int(os.environ["M_SSH_INV"]),
|
||||
"accepted": int(os.environ["M_SSH_OK"]),
|
||||
},
|
||||
"docker": {
|
||||
"running": int(os.environ["M_D_RUN"]),
|
||||
"exited": int(os.environ["M_D_EXIT"]),
|
||||
"unhealthy": int(os.environ["M_D_BAD"]),
|
||||
"unhealthy_names": [n for n in os.environ.get("M_D_BAD_N","").split(",") if n],
|
||||
},
|
||||
"tls": {
|
||||
"domain": os.environ["M_DOMAIN"],
|
||||
"cn": os.environ.get("M_CERT_CN") or None,
|
||||
"days_left": int(os.environ["M_CERT_DAYS"]),
|
||||
},
|
||||
"alerts": [a for a in os.environ.get("M_ALERTS","").split(",") if a],
|
||||
}
|
||||
print(json.dumps(data, ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
export M_HOST="$hostname_f" M_KERNEL="$kernel" M_UPTIME="$uptime_s" M_CPU="$cpu_n"
|
||||
export M_LOADAVG="$loadavg" M_LOAD1="$load1" M_MEM_PCT="$mem_used_pct" M_DISK_PCT="$disk_used_pct"
|
||||
export M_D_DISK_PATH="$DOCKER_ROOT" M_D_DISK_PCT="$docker_disk_used_pct" M_D_DISK_SAME="$docker_disk_same_as_root"
|
||||
export M_REBOOT="$reboot_required" M_APT="$pending_pkgs" M_APT_SEC="$pending_security"
|
||||
export M_UFW="$ufw_status" M_PORTS="$listen_ports"
|
||||
export M_F2B="$f2b_active" M_F2B_JAILS="$f2b_jails" M_F2B_NOW="$f2b_banned_now"
|
||||
export M_F2B_TOT="$f2b_banned_total" M_F2B_FAIL="$f2b_failed_total"
|
||||
export M_SSH_FAIL="$ssh_failed_1h" M_SSH_INV="$ssh_invalid_1h" M_SSH_OK="$ssh_accepted_1h"
|
||||
export M_D_RUN="$docker_running" M_D_EXIT="$docker_exited" M_D_BAD="$docker_unhealthy" M_D_BAD_N="$docker_names_unhealthy"
|
||||
export M_DOMAIN="$DOMAIN" M_CERT_CN="$cert_cn" M_CERT_DAYS="$cert_days" M_ALERTS="$alert_joined"
|
||||
|
||||
if [[ "$MODE" == "json" ]]; then
|
||||
out="$(render_json)"
|
||||
else
|
||||
out="$(render_text)"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$out"
|
||||
|
||||
if [[ -n "$LOG_FILE" ]]; then
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
printf '%s\n' "$out" >>"$LOG_FILE"
|
||||
fi
|
||||
|
||||
if [[ "$ALERT" -eq 1 && "$alert_count" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,53 @@
|
||||
# han-vm-metrics.sh
|
||||
Установка на ВМ
|
||||
sudo mkdir -p /opt/han-chat/ops
|
||||
Cкопировать han-vm-metrics.sh на сервер, затем:
|
||||
sudo chmod +x /opt/han-chat/ops/han-vm-metrics.sh
|
||||
sudo /opt/han-chat/ops/han-vm-metrics.sh
|
||||
JSON:
|
||||
|
||||
sudo /opt/han-chat/ops/han-vm-metrics.sh --json
|
||||
Cron каждые 5 минут + лог + ненулевой exit при проблемах:
|
||||
|
||||
echo '*/5 * * * * root /opt/han-chat/ops/han-vm-metrics.sh --alert --log /var/log/han-vm-metrics.log' | sudo tee /etc/cron.d/han-vm-metrics
|
||||
Пороги можно менять env: DISK_WARN_PCT, DISK_CRIT_PCT, MEM_WARN_PCT, CERT_WARN_DAYS, SSH_FAIL_WARN.
|
||||
|
||||
# diagnose-han-chat.sh
|
||||
|
||||
|
||||
# КОнтроль места
|
||||
docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.ID}}\t{{.CreatedSince}}'
|
||||
docker builder du
|
||||
|
||||
# Очистка build cache
|
||||
docker builder prune -af
|
||||
|
||||
# Cron раз в сутки (03:15) + лог
|
||||
echo '15 3 * * * root /usr/bin/docker builder prune -af >> /var/log/docker-builder-prune.log 2>&1' | sudo tee /etc/cron.d/docker-builder-prune
|
||||
sudo chmod 644 /etc/cron.d/docker-builder-prune
|
||||
|
||||
Логи
|
||||
cat /etc/cron.d/docker-builder-prune
|
||||
tail -n 20 /var/log/docker-builder-prune.log
|
||||
|
||||
# Проверка места
|
||||
df -h /
|
||||
docker system df
|
||||
|
||||
|
||||
# Обновление секретов
|
||||
|
||||
### Как проверить, что sync реально отработал
|
||||
|
||||
```sh
|
||||
# 1) Когда unit реально выполнялся
|
||||
systemctl show han-secrets-vm2.service -p ActiveState -p SubState -p Result -p ExecMainStartTimestamp -p ExecMainStatus
|
||||
|
||||
# 2) Журнал последней попытки
|
||||
journalctl -u han-secrets-vm2.service -n 50 --no-pager
|
||||
|
||||
# 3) Принудительно пересинхронизировать
|
||||
systemctl restart han-secrets-vm2.service
|
||||
echo exit:$?
|
||||
systemctl show han-secrets-vm2.service -p ExecMainStartTimestamp -p Result -p ExecMainStatus
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
cd /opt/han-chat/backend
|
||||
|
||||
echo "=== CONTAINERS ==="
|
||||
docker compose --env-file .env ps
|
||||
|
||||
echo "=== BITRIX STATUS ==="
|
||||
docker compose --env-file .env exec -T api-backend python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
request = urllib.request.Request(
|
||||
os.environ["BITRIX_LOCAL_APP_BASE_URL"] + "/internal/openlines/v1/status",
|
||||
headers={
|
||||
"Authorization": "Bearer "
|
||||
+ os.environ["BITRIX_LOCAL_APP_INTERNAL_TOKEN"]
|
||||
},
|
||||
)
|
||||
print(json.dumps(
|
||||
json.load(urllib.request.urlopen(request, timeout=10)),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
))
|
||||
PY
|
||||
|
||||
echo "=== BITRIX LOGS ==="
|
||||
docker compose --env-file .env logs \
|
||||
--since=15m --timestamps bitrix-local-app
|
||||
|
||||
echo "=== API LOGS ==="
|
||||
docker compose --env-file .env logs \
|
||||
--since=15m --timestamps api-backend
|
||||
|
||||
echo "=== NGINX LOGS ==="
|
||||
docker compose --env-file .env logs \
|
||||
--since=15m --timestamps nginx
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Безопасная загрузка .env: убираем комментарии, удаляем \r (Windows-переносы) и делаем export
|
||||
export $(grep -v '^#' .env | sed 's/\r$//' | xargs)
|
||||
|
||||
echo "=== 1. Параметры из .env ==="
|
||||
grep -E '^(HAN_PG_HOST|HAN_PG_PORT|KEYCLOAK_DB_)' .env | grep -v PASSWORD || echo "Переменные не найдены!"
|
||||
|
||||
# 2. Проверка TCP (используем переменные, а не хардкод)
|
||||
echo -e "\n=== 2. Проверка TCP (${HAN_PG_HOST}:${HAN_PG_PORT}) ==="
|
||||
if command -v nc &> /dev/null; then
|
||||
nc -zv "${HAN_PG_HOST}" "${HAN_PG_PORT}" || echo "❌ Не удалось подключиться по TCP"
|
||||
else
|
||||
echo "⚠️ Утилита nc не найдена, пропускаем проверку TCP"
|
||||
fi
|
||||
|
||||
# 3. Проверка подключения psql
|
||||
echo -e "\n=== 3. Проверка подключения к БД ==="
|
||||
|
||||
# ВАЖНО: Передаем пароль через переменную окружения, чтобы скрипт не зависал!
|
||||
# Если KEYCLOAK_DB_PASSWORD пуст, psql все равно спросит его вручную.
|
||||
export PGPASSWORD="${KEYCLOAK_DB_PASSWORD:-}"
|
||||
|
||||
# Проверяем, установлен ли psql
|
||||
if ! command -v psql &> /dev/null; then
|
||||
echo "❌ Утилита psql не найдена на хост-машине. Установите postgresql-client."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Выполняем запрос. Используем переменную порта и добавляем fallback для sslrootcert, если он не задан
|
||||
psql "host=${HAN_PG_HOST} port=${HAN_PG_PORT} dbname=han_chat user=${KEYCLOAK_DB_USERNAME} sslmode=verify-full sslrootcert=${PG_CA_HOST_PATH:-}" \
|
||||
-c "SELECT current_user, current_schema(); SHOW search_path;"
|
||||
|
||||
# Очищаем пароль из окружения после выполнения (хорошая практика)
|
||||
unset PGPASSWORD
|
||||
@@ -0,0 +1,202 @@
|
||||
# Отправка СМС
|
||||
|
||||
## На ВМ выполните:
|
||||
|
||||
cd /opt/han-chat/backend
|
||||
umask 077
|
||||
|
||||
REQUEST_FILE=$(mktemp)
|
||||
read -r -p "Тестовый номер в E.164 (+79...): " TEST_PHONE
|
||||
python3 - "$REQUEST_FILE" "$TEST_PHONE" <<'PY'
|
||||
import json
|
||||
import secrets
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
challenge_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
"idempotency_key": f"ops:smoke:{challenge_id}",
|
||||
"template_code": "auth_otp",
|
||||
"locale": "ru",
|
||||
"phone_e164": sys.argv[2],
|
||||
"substitutions": {
|
||||
"code": f"{secrets.randbelow(1000000):06d}",
|
||||
"ttl_min": "1",
|
||||
},
|
||||
"customer_ref": challenge_id,
|
||||
"message_ttl_sec": 60,
|
||||
}
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as file:
|
||||
json.dump(payload, file, ensure_ascii=False)
|
||||
PY
|
||||
|
||||
|
||||
## Создайте функцию отправки:
|
||||
|
||||
send_sms_smoke() {
|
||||
deployment/secrets/han-secrets run --config .env -- \
|
||||
docker compose --env-file .env --profile ops run --rm --no-deps \
|
||||
--user 0:0 \
|
||||
--entrypoint sh \
|
||||
-v /run/han-chat/secrets/SMS_SERVICE_TOKEN:/run/secrets/sms_service_token:ro \
|
||||
-v "$REQUEST_FILE:/tmp/sms-request.json:ro" \
|
||||
toolbox -ec '
|
||||
umask 077
|
||||
printf "Authorization: Bearer %s\n" \
|
||||
"$(cat /run/secrets/sms_service_token)" > /tmp/auth-header
|
||||
curl -sS \
|
||||
-w "\nHTTP %{http_code}\n" \
|
||||
-X POST \
|
||||
-H @/tmp/auth-header \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Request-ID: ops-sms-smoke" \
|
||||
--data-binary @/tmp/sms-request.json \
|
||||
http://sms-service:8080/internal/sms/v1/send
|
||||
'
|
||||
}
|
||||
|
||||
## Отправка:
|
||||
send_sms_smoke
|
||||
|
||||
Ожидается:
|
||||
HTTP 202 и JSON с sms_message_id.
|
||||
|
||||
## Проверьте журнал:
|
||||
SELECT
|
||||
id,
|
||||
phone_masked,
|
||||
send_status,
|
||||
delivery_status,
|
||||
provider_message_id,
|
||||
provider_error_code,
|
||||
attempt_count,
|
||||
created_at
|
||||
FROM sms.sms_outbound_message
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
|
||||
## После проверки удалите секретные данные:
|
||||
shred -u "$REQUEST_FILE" 2>/dev/null || rm -f "$REQUEST_FILE"
|
||||
unset TEST_PHONE REQUEST_FILE
|
||||
|
||||
# Тесты
|
||||
|
||||
Выполняйте на ВМ из `/opt/han-chat/backend`.
|
||||
|
||||
### 1. Проверить запрет публичного internal API
|
||||
|
||||
```bash
|
||||
PUBLIC_WEB_URL=$(python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
for line in Path(".env").read_text().splitlines():
|
||||
if line.startswith("PUBLIC_WEB_URL="):
|
||||
print(line.split("=", 1)[1].strip().strip("\"'"))
|
||||
break
|
||||
PY
|
||||
)
|
||||
|
||||
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' \
|
||||
"$PUBLIC_WEB_URL/internal/sms/v1/messages/00000000-0000-0000-0000-000000000000"
|
||||
```
|
||||
|
||||
Ожидается:
|
||||
|
||||
```text
|
||||
HTTP 404
|
||||
```
|
||||
|
||||
### 2. Проверить callback с неправильного IP
|
||||
|
||||
```bash
|
||||
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' \
|
||||
-X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '[]' \
|
||||
"$PUBLIC_WEB_URL/callbacks/idgtl/sms"
|
||||
```
|
||||
|
||||
Ожидается:
|
||||
|
||||
```text
|
||||
HTTP 403
|
||||
```
|
||||
|
||||
Заголовок `X-Forwarded-For` не должен позволять обойти ограничение.
|
||||
|
||||
### 3. Проверить Basic auth внутри Docker-сети
|
||||
|
||||
Credentials получает только дочерний процесс через secret launcher.
|
||||
|
||||
Неверные credentials:
|
||||
|
||||
```bash
|
||||
deployment/secrets/han-secrets run --config .env -- \
|
||||
docker compose --env-file .env --profile ops run --rm --no-deps \
|
||||
--entrypoint sh toolbox -ec '
|
||||
curl -sS -o /dev/null -w "HTTP %{http_code}\n" \
|
||||
-u invalid:invalid \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "[]" \
|
||||
http://sms-service:8080/callbacks/idgtl/sms
|
||||
'
|
||||
```
|
||||
|
||||
Ожидается `HTTP 401`.
|
||||
|
||||
Правильные credentials:
|
||||
|
||||
```bash
|
||||
deployment/secrets/han-secrets run --config .env -- \
|
||||
docker compose --env-file .env --profile ops run --rm --no-deps \
|
||||
--entrypoint sh \
|
||||
-v /run/han-chat/secrets/IDGTL_SMS_CALLBACK_USERNAME:/run/secrets/callback_user:ro \
|
||||
-v /run/han-chat/secrets/IDGTL_SMS_CALLBACK_PASSWORD:/run/secrets/callback_password:ro \
|
||||
toolbox -ec '
|
||||
umask 077
|
||||
{
|
||||
printf "user = \"%s:%s\"\n" \
|
||||
"$(cat /run/secrets/callback_user)" \
|
||||
"$(cat /run/secrets/callback_password)"
|
||||
} > /tmp/curl-auth.conf
|
||||
curl -sS -o /dev/null -w "HTTP %{http_code}\n" \
|
||||
--config /tmp/curl-auth.conf \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "[]" \
|
||||
http://sms-service:8080/callbacks/idgtl/sms
|
||||
'
|
||||
```
|
||||
|
||||
Ожидается `HTTP 422`: авторизация прошла, но пустой callback-массив невалиден.
|
||||
|
||||
### 4. Проверить реальный callback Direct
|
||||
|
||||
После тестовой SMS:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
id,
|
||||
send_status,
|
||||
delivery_status,
|
||||
provider_message_id,
|
||||
callback_last_at,
|
||||
sent_at,
|
||||
delivered_at
|
||||
FROM sms.sms_outbound_message
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
Успешный реальный callback подтверждается:
|
||||
|
||||
- `callback_last_at IS NOT NULL`;
|
||||
- `delivery_status = sent` или `delivered`;
|
||||
- заполняются `sent_at`/`delivered_at`.
|
||||
|
||||
Дополнительно:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env logs --since=30m nginx sms-service
|
||||
```
|
||||
|
||||
Для callback должен быть ответ `204`. Только реальный запрос Direct может полноценно подтвердить IP allowlist.
|
||||
@@ -0,0 +1,70 @@
|
||||
### 1. Получите digest локально
|
||||
|
||||
В WSL:
|
||||
|
||||
```sh
|
||||
docker pull --platform linux/amd64 clamav/clamav:1.4.6
|
||||
|
||||
docker image inspect clamav/clamav:1.4.6 \
|
||||
--format '{{index .RepoDigests 0}}'
|
||||
```
|
||||
|
||||
Получите значение вида:
|
||||
|
||||
```text
|
||||
clamav/clamav@sha256:<DIGEST>
|
||||
```
|
||||
|
||||
### 2. Обновите `.env`
|
||||
|
||||
Локально и на ВМ замените:
|
||||
|
||||
```dotenv
|
||||
CLAMAV_IMAGE=clamav/clamav@sha256:<НОВЫЙ_DIGEST>
|
||||
```
|
||||
|
||||
На ВМ предварительно сохраните резервную копию:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/services
|
||||
cp -a .env ".env.before-clamav-1.4.6-$(date +%Y%m%d-%H%M%S)"
|
||||
editor .env
|
||||
```
|
||||
|
||||
Не указывайте в production только тег `:1.4.6` — нужен digest.
|
||||
|
||||
### 3. Загрузите и пересоздайте контейнеры
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/services
|
||||
|
||||
deployment/preflight.sh
|
||||
/usr/local/sbin/han-vm2-compose config --quiet
|
||||
/usr/local/sbin/han-vm2-compose config --images
|
||||
|
||||
/usr/local/sbin/han-vm2-compose pull clamd freshclam
|
||||
|
||||
/usr/local/sbin/han-vm2-compose up -d --force-recreate \
|
||||
freshclam clamd
|
||||
```
|
||||
|
||||
Не используйте `down -v`: volume сигнатур должен сохраниться.
|
||||
|
||||
### 4. Проверка
|
||||
|
||||
Через несколько минут:
|
||||
|
||||
```sh
|
||||
/usr/local/sbin/han-vm2-compose ps clamd freshclam
|
||||
|
||||
/usr/local/sbin/han-vm2-compose exec -T clamd \
|
||||
clamdscan --version
|
||||
|
||||
/usr/local/sbin/han-vm2-compose exec -T clamd \
|
||||
clamdscan --ping 1
|
||||
|
||||
/usr/local/sbin/han-vm2-compose logs --no-color --since 10m \
|
||||
clamd freshclam
|
||||
```
|
||||
|
||||
Ожидается версия `1.4.6`, `clamd` — `healthy`, `freshclam` — `Up`. CDN cooldown сохранится до указанного времени; обходить его удалением файлов или volume не нужно.
|
||||
Reference in New Issue
Block a user