# Ранбук развёртывания Processing на VM2 Этот каталог — независимая основа VM2 в репозитории `VM2_services`. Он не разворачивает VM1 и не затрагивает `VM1_app/codebase/backend`. Все команды ниже — операторские; создание репозитория их не выполняет. Корень репозитория ВМ2: `HAN_chat_specification/VM2_services`. Compose и deployment-артефакты: `VM2_services/codebase/services/`. Локальные команды ниже предполагают текущий каталог `VM2_services`, если не указано иное. ## Блокеры production перед первым запуском 1. Замените каждый плейсхолдер в `.env` на проверенные несекретные значения. Держите `BITRIX_SYNC_ENABLED=false`, пока не подписаны миграции, гранты, поля портала, контракты роботов и cutover. (для этого нужно еще образы отправить в conteiner registry, пункт 2) 2. Заполните каждую переменную `*_IMAGE` проверенным digest из registry. Корневой Compose отклоняет отсутствующие ссылки на образы; изменяемые теги не являются доказательством для production. 3. Устанавливайте production-файлы от `root:root`; пользователь `deploy` не должен входить в группу `docker` и не должен иметь возможность писать Compose, unit-файлы, хелперы, allow-list’ы или маппинги секретов. (смысл: заходим под админом, sudo -i) 4. Заполните отдельные проверенные активные CIDR-файлы из двух `.template`. Их закоммиченные активные версии намеренно содержат `deny all`. (в services/nginx/allowlist прописываем разрешенные адреса - адрес ВМ1 и адрес битрикса) 5. Выпустите публичный ACME-сертификат в host-каталог `/etc/letsencrypt`. Разместите CA управляемой PostgreSQL в `/etc/han/ca`. Выпустите сертификат внутренней CA, SAN которого совпадает с приватным именем VM2. Разрешайте хостовый порт `8443` только из SG VM1 и, при необходимости, одобренных приватных/VPN-сетей операторов. (выпуск сертификатов) 6. Создайте отдельный IAM-принципал Selectel для VM2. Он может читать только имена из `deployment/secrets/config.example.json`. Никогда не переиспользуйте принципал VM1. (отдельный проект в селектел, туда отдельного сервисного пользователя с ролью member) 7. `REDIS_SAFETY_ACL` — полный ACL-файл, а не просто пароль. Он должен открывать неаутентифицированный `PING` только для health и защищённого паролем пользователя `safety`, ограниченного необходимыми ключами/командами `han:safety:*`. Добавьте пользователя `exporter` только с `PING`/`INFO`; его пароль в ACL должен в точности совпадать с отдельным `REDIS_EXPORTER_PASSWORD`. Этот secret хранится в формате JSON password map: `{"redis://redis-safety:6379":"<ТОТ_ЖЕ_ПАРОЛЬ>"}`, а не как голая строка. Пароль `safety` в `MESSAGE_SAFETY_REDIS_URL` также должен совпадать с ACL. Используйте `redis/redis-safety.acl.template`, заменив оба плейсхолдера. 8. Выделите отдельные учётные данные БД для runtime и миграций. `MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` может мигрировать/активировать политику, а `MESSAGE_SAFETY_DATABASE_URL` — нет; `BITRIX_SYNC_MIGRATION_DATABASE_URL` владеет DDL, а `BITRIX_SYNC_DATABASE_URL` — runtime-роль с минимальными привилегиями. Учётные данные миграций монтируются только в jobs профиля `ops`. 9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До production ограничьте egress правилами Selectel SG/NAT/proxy до утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP и SigNoz. Для host KESL разрешите только источники обновления из [`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md). Registry/package repositories оставляйте только на controlled maintenance window. ## Кто что выполняет - **Локальный компьютер оператора:** создаёт архив релиза и передаёт его на VM2. Локальные команды ниже показаны для PowerShell. - **`root` на VM2:** только bootstrap host OS, активация проверенного релиза, установка root-owned файлов, настройка `.env`, secret mapping, credentials, TLS/allow-list, миграции и первый старт. - **`deploy` на VM2:** принимает релиз только в `/var/lib/han-deploy/incoming`, проверяет статус/логи и запускает уже установленные fixed systemd operations через точные sudo-правила. `deploy` не запускает `docker`, не редактирует `/opt/han-chat/services` и не входит в группу `docker`. - **`admin` на VM2:** персональная break-glass роль с отдельным SSH-ключом и отдельным локальным паролем для `sudo`. Не используется для штатного деплоя, не входит в `docker`/`lxd`; каждый вход и sudo-вызов считается инцидентной операцией. ## Предварительные условия (до §1) Этот runbook описывает операции **на уже созданной VM2**. До bootstrap подготовьте вне Compose: 1. **Инфраструктура Selectel** — VPC/subnet, SG (`80/443/22` public; `8443` только private; egress default-deny после bootstrap), sizing (4 vCPU / 8 ГБ RAM / 80 ГБ SSD — см. [`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md)), public и private IP VM2, DNS A-запись `PROCESSING_PUBLIC_HOST`. 2. **Managed PostgreSQL** — schemas/roles для `message_safety` и `bitrix_sync`, отдельные migration/runtime DSN; см. [`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6. 3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`; получить immutable digest для всех `*_IMAGE` в `.env.example` (nginx, redis, otel-collector, Redis exporter, nginx exporter). `clamd`/`freshclam` в Compose отсутствуют: KESL 12.4 standalone и broker устанавливаются на host. 4. **Selectel Secrets Manager** — заполнить все remote names из `deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys, `REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM для `8443`). Отдельный IAM principal VM2 с read-only доступом только к этим именам. 5. **S3 quarantine bucket** и SigNoz OTLP endpoint — значения в `.env`. Текущий self-hosted SigNoz принимает private plaintext OTLP без auth, поэтому не создавайте фиктивный auth-secret или обязательный непустой header. 6. **Internal TLS** — сертификат внутренней CA с SAN = private DNS VM2; PEM хранится в Secrets Manager, не в каталоге релиза. Порядок разделов §1–§5 → Gates 1–9 → §7 (post-acceptance). `systemctl enable` и `systemctl start han-processing.service` — **только после успешного Gate 5**. ## 1. Bootstrap свежей VM2 На локальном компьютере один раз создайте **два разных** ключа. Закрытые части остаются только у соответствующих операторов и никогда не передаются на VM: ```powershell 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" ``` Для production ключ `admin` должен принадлежать отдельному назначенному break-glass оператору и храниться отдельно от deploy key. Если команды выполняет один человек на этапе bootstrap, это всё равно две разные key pairs с раздельной последующей передачей/ротацией. Скопируйте setup-скрипт и только публичные части ключей во временный root каталог: ```powershell # текущий каталог: ...\HAN_chat_specification\VM2_services scp -i C:\Users\MI\.ssh\hansel ` .\codebase\services\deployment\scripts\setup-vm.sh ` root@:/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@:/root/ ``` На VM2 в текущей root-сессии задайте приватный CIDR VM1. `/0` скрипт отклоняет: ```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='/32' \ /root/setup-vm2.sh ``` Скрипт устанавливает Ubuntu-пакеты, Docker Engine + Compose plugin, UFW, fail2ban, unattended upgrades, swap, sysctl и цепочку `DOCKER-USER`; создаёт `deploy`, break-glass `admin`, staging и root-owned production-каталог. Скрипт не запускает Compose/контейнеры. `80/443` и SSH открываются публично; SSH остаётся key-only и защищён fail2ban. `8443` доступен только на приватном IP VM2 из `VM1_PRIVATE_CIDRS`. Если оператору нужен прямой доступ к внутреннему API через приватный маршрут или VPN, дополнительно передайте необязательный `OPS_CIDRS=''`. В текущей root-сессии задайте `admin` отдельный сложный sudo-пароль. Он не разрешает password SSH: пароль нужен только после входа по admin key: ```sh passwd admin ``` Не закрывая root-сессию, на локальном компьютере проверьте оба входа: ```powershell ssh -i C:\Users\MI\.ssh\han_vm2_deploy deploy@ ssh -i C:\Users\MI\.ssh\han_vm2_admin admin@ ``` В admin-сессии проверьте запрос именно admin-пароля и получение root shell, после чего сразу выйдите из него: ```sh sudo -v sudo -i id exit ``` Только после успешной проверки `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='/32' \ HARDEN_SSH=true \ SKIP_APT_UPGRADE=true \ /root/setup-vm2.sh ``` Это добавит `PermitRootLogin no` и `AllowUsers deploy admin`. Ещё раз откройте обе новые SSH-сессии после reload и только затем закрывайте старую root. Публичные bootstrap-копии после проверки можно удалить под `admin`: ```sh sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub ``` ## 2. Передача релиза под `deploy` На локальном компьютере из каталога `VM2_services`: ```powershell $Release = "" tar --exclude=services/.env ` --exclude='services/**/__pycache__' ` --exclude='services/**/.pytest_cache' ` --exclude='services/**/.ruff_cache' ` -czf "vm2-services-$Release.tar.gz" -C .\codebase services Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256 scp -i C:\Users\MI\.ssh\hansel "vm2-services-$Release.tar.gz" ` deploy@:/var/lib/han-deploy/incoming/ ``` Под `deploy` на VM2 вычислите checksum. Значение должно совпасть с локальным: ```sh RELEASE='' cd /var/lib/han-deploy/incoming sha256sum "vm2-services-${RELEASE}.tar.gz" tar -tzf "vm2-services-${RELEASE}.tar.gz" ``` На этом действия `deploy` с файлами заканчиваются. Не распаковывайте релиз через `sudo` и не копируйте его в production от имени `deploy`. ## 3. Активация и установка файлов под `root` Под `root` ещё раз сверьте ожидаемый SHA-256 и список архива. Не продолжайте, если архив содержит абсолютные пути, `..`, symlink/hardlink или лишний проект: ```sh RELEASE='' EXPECTED_SHA256='' 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 ``` Повторите setup под `root`: теперь он установит helpers и units из активного релиза. Приложение всё ещё не запускается: ```sh DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ VM1_PRIVATE_CIDRS='/32' \ HARDEN_SSH=true \ SKIP_APT_UPGRADE=true \ /root/setup-vm2.sh ``` Скрипт устанавливает: - `/usr/local/lib/han-secrets-vm2/{secrets_loader.py,han-secrets}`; - `/usr/local/sbin/han-vm2-compose`; - `/usr/local/sbin/han-message-safety-mode`; - `/etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx`; - `/etc/systemd/system/{han-secrets-vm2,han-processing}.service`; - `/etc/sudoers.d/{han-vm2-deploy,deploy-message-safety-mode}`; - группу `han-message-safety` с GID `10001`; - стандартный `/etc/han-chat/message-safety-mode.env` с правами `root:han-message-safety 0640`. ## 4. Несекретная конфигурация и 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 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 ``` В `.env` заменяются только несекретные плейсхолдеры и image digests. Значения DSN, token, password, access/secret key туда не записываются. Для Selectel создайте отдельный VM2 IAM principal с read-only доступом только к remote names из mapping. Локальный Collector принимает traces, metrics и logs напрямую по OTLP; чтение Docker JSON через `filelog` не используется. Prometheus receiver собирает только метрики самого Collector, `redis-exporter` и `nginx-exporter`, а `hostmetrics` — метрики VM через read-only `/hostfs`. Exporter-контейнеры имеют только `expose` во внутренних сетях и не публикуют host ports. В nginx endpoint `/stub_status` слушает только внутренний `8081`. Зашифруйте пароль 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 editor /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 ``` Для контролируемого восстановления без провайдера используйте явный `file` config и root-only каталог `0700` с одним файлом на ключ. При сбое Selectel автоматический fallback запрещён. ## 5. Сертификат PostgreSQL и первоначальный выпуск public TLS ### CA управляемой PostgreSQL Скачайте CA-сертификат кластера из панели провайдера и передайте его на VM2 во временный путь. Под `root` установите сертификат вне каталога релиза: ```sh install -d -m 0755 -o root -g root /etc/han/ca install -m 0644 -o root -g root \ /tmp/ \ /etc/han/ca/managed-postgresql-ca.pem openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \ -noout -subject -issuer -dates rm -f /tmp/ ``` В `.env` должно быть: ```dotenv PG_CA_HOST_PATH=/etc/han/ca/managed-postgresql-ca.pem ``` Compose монтирует этот файл read-only во все runtime и migration контейнеры как `/run/config/postgresql-ca.pem`. DB-клиенты создают обязательный TLS context с проверкой цепочки и имени сервера по этому CA. Не добавляйте libpq-параметры `sslmode`/`sslrootcert` в SQLAlchemy `postgresql+asyncpg` URL: asyncpg получает SSL context отдельно, а такие query-параметры могут быть переданы как неподдерживаемые keyword arguments. DSN в Secrets Manager имеет обычный вид: ```text postgresql+asyncpg://:@:/ ``` ### Первоначальный выпуск Let's Encrypt `PROCESSING_PUBLIC_HOST` должен быть DNS-именем, A-запись которого уже указывает на публичный IP VM2. Сертификат на IP-адрес этим порядком не выпускается. Порт `80` должен быть разрешён в cloud firewall/UFW и пока не занят nginx. Под `root` задайте значения только для текущей shell-сессии и подготовьте постоянный webroot: ```sh PUBLIC_HOST='' ACME_EMAIL='' 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" getent group han-nginx-tls test -d /var/lib/han-chat/public-tls install -m 0640 -o root -g han-nginx-tls \ "/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem" \ /var/lib/han-chat/public-tls/fullchain.pem install -m 0640 -o root -g han-nginx-tls \ "/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem" \ /var/lib/han-chat/public-tls/privkey.pem ``` Nginx с primary GID `11001` получает только подготовленные public certificate и private key из `/var/lib/han-chat/public-tls` с host read-only. Исходный `/etc/letsencrypt` остаётся доступен только root/Certbot. Не копируйте private key в каталог релиза и не делайте его world-readable. ## 6. Gates 1–9: preflight, миграции и первый запуск под `root` Выполняйте gates **строго по порядку** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Не включайте `han-processing.service` и не делайте `systemctl enable`, пока Gate 5 не завершился успешно. Установка/редактирование unit, Compose, `.env`, secret mapping, credential, TLS, allow-list и запуск migration jobs остаются операциями `root`. После Gate 5 штатный restart/status/logs для `deploy` — см. блок «Дальнейшие штатные операции» ниже. ### 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`. Перед первым `bitrix-sync-migrate` владелец `han_app` или администратор БД выдаёт Bitrix migration-role временный read-only доступ к legacy mapping: ```sql GRANT USAGE ON SCHEMA han_app TO ; GRANT SELECT ON TABLE han_app.entity_external_mapping TO ; ``` ```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 '' /usr/local/sbin/han-vm2-compose --profile ops run --rm \ --entrypoint message-safety-config message-safety-migrate \ activate --version 1 --approved-by '' ``` После успешного `bitrix-sync-migrate` администратор БД отзывает временные права. Право `USAGE` отзывайте только если оно не требуется этой роли для других согласованных операций: ```sql REVOKE SELECT ON TABLE han_app.entity_external_mapping FROM ; REVOKE USAGE ON SCHEMA han_app FROM ; ``` Проверьте head revision и активную config version: ```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 запрещён. При обновлении KESL policy образ Message Safety должен содержать согласованные seed и schema: seed `max_signature_age_hours=240`, schema maximum `720` (30 дней). После обновления immutable image digest создайте новую config version; существующую active version не редактируйте и не активируйте повторно: ```sh NEXT_VERSION='<СЛЕДУЮЩИЙ_МОНОТОННЫЙ_НОМЕР>' /usr/local/sbin/han-vm2-compose --profile ops pull \ message-safety-migrate /usr/local/sbin/han-vm2-compose --profile ops run --rm \ --entrypoint message-safety-config message-safety-migrate \ validate /app/app/artifacts/seed-config.yaml /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 "$NEXT_VERSION" --actor '' /usr/local/sbin/han-vm2-compose --profile ops run --rm \ --entrypoint message-safety-config message-safety-migrate \ activate --version "$NEXT_VERSION" --approved-by '' /usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \ message-safety-api message-safety-worker /usr/local/sbin/han-vm2-compose ps \ message-safety-api message-safety-worker unset NEXT_VERSION ``` Старый image, schema которого ограничивает поле значением `168`, нельзя оставлять после активации значения `240`: сначала обновите `MESSAGE_SAFETY_IMAGE` на новый digest и проверьте `config --quiet`. ### 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 cd /opt/han-chat/services # Сначала полностью выполните operator runbook: # deployment/kesl/RUNBOOK.KESL.ru.md systemctl enable --now han-kesl-scan-broker.socket systemctl --no-pager status kesl han-kesl-scan-broker.socket test -S /run/han-kesl/scan.sock stat -c '%U:%G %a %n' /run/han-kesl/scan.sock /usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector /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 ``` До запуска Message Safety operator KESL runbook обязан подтвердить KESL 12.4 standalone, успешное ежечасное обновление, допустимый database date и canary. Socket должен иметь `root:han-message-safety 0660` и не быть доступен посторонним UID. Broker является custom integration: неизвестный output/exit code `kesl-control --scan-file --action Inform` считается scanner error, а не clean. Формат и throughput проверяются на target VM2. `otel-collector` автоматически запускает одноразовый `otel-queue-init`. Он выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`; сам Collector стартует только после этого. Healthcheck nginx использует встроенный `nginx -t`: утверждённый `nginx-unprivileged` image не содержит `wget`/`curl`. `ExitCode 127` с сообщением `wget: not found` означает, что на VM2 остался старый Compose. Если запуск выполнялся со старым релизом и Message Safety уже попал в permission/restart loop, после активации исправленного релиза под `root` восстановите контракт файла и пересоздайте затронутые контейнеры: ```sh getent group 10001 >/dev/null || groupadd --system --gid 10001 han-message-safety test "$(getent group han-message-safety | cut -d: -f3)" = 10001 chown root:han-message-safety /etc/han-chat/message-safety-mode.env chmod 0640 /etc/han-chat/message-safety-mode.env install -m 0755 -o root -g root \ /opt/han-chat/services/deployment/han-message-safety-mode \ /usr/local/sbin/han-message-safety-mode /opt/han-chat/services/deployment/preflight.sh /usr/local/sbin/han-vm2-compose up -d --force-recreate \ otel-queue-init otel-collector /usr/local/sbin/han-vm2-compose up -d --force-recreate \ message-safety-api message-safety-worker /usr/local/sbin/han-vm2-compose ps ``` Не заменяйте это на `chmod 0644/0666`, запуск контейнеров от root или рекурсивный `chown` Docker volumes. Если после восстановления прав nginx остаётся в `Restarting`, это отдельная ошибка конфигурации/TLS, а не права Message Safety; проверьте её без вывода секретов: ```sh /usr/local/sbin/han-vm2-compose logs --tail 100 nginx otel-collector ``` Дождитесь `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 journalctl --no-pager -u han-processing.service ``` Дальнейшие штатные операции может выполнить `deploy`: ```sh sudo systemctl restart han-secrets-vm2.service sudo systemctl restart han-processing.service sudo systemctl --no-pager status han-processing.service sudo journalctl --no-pager -u han-processing.service ``` ### 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 :443 \ -servername \ -verify_hostname -verify_return_error :8443 \ -servername \ -verify_hostname \ -CAfile -verify_return_error /not-a-route curl -sS -o /dev/null -w '%{http_code}\n' \ https:///not-a-route curl -sS -o /dev/null -w '%{http_code}\n' \ http:///bitrix/sync/webhook/contact curl -sS -o /dev/null -w '%{http_code}\n' \ https:///internal/safety/status curl -sS -o /dev/null -w '%{http_code}\n' -X GET \ https:///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 \ https://:8443/internal/safety/status ``` Benign text check через тот же private listener: ```sh SAFETY_TOKEN="$(cat )" MESSAGE_ID="$(uuidgen)" curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - </bitrix/sync/webhook/contact?token=${CANARY}" ``` На VM2 под `root`: ```sh CANARY='<ЗНАЧЕНИЕ_CANARY_С_ТЕСТОВОЙ_МАШИНЫ>' 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`. ## 7. После Gate 9 — post-acceptance Gate 9 завершает техническую приёмку VM2, но **не** означает production cutover Message Safety на VM1 и **не** разрешает включать Bitrix sync. Дальнейший порядок: 1. Проверить автозапуск: ```sh systemctl is-enabled \ docker.service \ han-chat-vm2-docker-firewall.service \ han-secrets-vm2.service \ han-processing.service \ certbot.timer systemctl is-active \ docker.service \ han-chat-vm2-docker-firewall.service \ han-processing.service \ certbot.timer /usr/local/sbin/han-vm2-compose ps ``` 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; - KESL version/database date, результат ежечасного update и broker/socket status; - 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-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md): поля портала, webhooks, migrations, grants, backfill/watermark и rollback rehearsal. После успешного Gate 9 выполните reboot-gate (п. 2 выше), зафиксируйте приёмку (п. 3), затем выполните отдельный controlled cutover ниже. Gate 9 сам по себе не разрешает переключать caller. ### Controlled cutover Message Safety на VM1 Cutover выполняется в согласованное окно совместно с Safety Service, Rule Pack, Security, Product и Operations. До начала зафиксируйте текущий и предыдущий schema-compatible immutable release VM1, ответственного за rollback и stop conditions. `bitrix-sync` в это окно не включается. #### 1. Предварительные условия До изменения VM1 должны быть выполнены все условия: - reboot-gate VM2 и повторные Gates 6–8 успешны; - Safety работает в `standard`, не в `mock`; - active config и rules bundle утверждены, Safety v2 migrations находятся на ожидаемом head; - `text`, `links`, `files`, `worker` имеют состояние `ready`; - VM2 имеет только read-only доступ к versioned S3 quarantine objects; - performance, egress negative tests и redaction Gate 9 закрыты; - private DNS VM2 резолвится с VM1 только в private VPC address; - security group/firewall разрешает `8443` от VM1 и approved ops, но не из интернета; - на VM1 подготовлен release без local `message-safety`, Redis DB2, local rules env и stub fallback; - один и тот же production service token подготовлен в раздельных secret bundles VM1 и VM2; значение токена не печатается и не копируется в `/etc/han/vm1.env`. На VM1 под `root` повторите проверку TLS и readiness: ```sh openssl s_client -connect :8443 \ -servername \ -verify_hostname \ -CAfile /etc/han/ca/vm2-internal-ca.pem \ -verify_return_error :8443/internal/safety/status ``` В TLS-выводе ожидается успешная проверка chain/SAN. В status ожидаются `processing_mode=standard`, непустой `config_version` и `text|links|files|worker=ready`. `stub`, `mock`, `not_ready` или недоступная capability — stop condition. #### 2. Прямой pre-cutover smoke с VM1 Прямой smoke доказывает route, CA и paired token до перезапуска caller: ```sh SAFETY_TOKEN="$(cat )" MESSAGE_ID="$(uuidgen)" curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <:8443 MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2 ``` В root-owned Selectel secret mapping VM1 переменная `MESSAGE_SAFETY_SERVICE_TOKEN` должна ссылаться на согласованный remote secret. После review config и mapping: ```sh cd /opt/han-chat/current/backend ./scripts/validate-env /etc/han/vm1.env systemctl restart han-secrets@production.service systemctl is-active han-secrets@production.service journalctl --no-pager -u han-secrets@production.service ./deployment/preflight.sh /usr/local/sbin/han-vm1-compose config --quiet /usr/local/sbin/han-vm1-compose config --services /usr/local/sbin/han-vm1-compose config --images ``` В `config --services` не должно быть local `message-safety`, `bitrix-sync`, Redis DB2 или test stub. Не сохраняйте resolved Compose в файл и не выводите secret values. Если preflight успешен, переключите caller: ```sh systemctl restart han-stack@production.service systemctl is-active han-stack@production.service /usr/local/sbin/han-vm1-compose ps ``` #### 4. Post-cutover проверки через caller Проверки выполняются через public API/штатный UI VM1, а не только прямым curl к VM2: 1. benign text проходит Safety и отправляется ровно один раз; 2. утверждённый deny text не отправляется в Bitrix и возвращает клиенту generic `message_blocked` без internal `rule_id`; 3. сообщение с безопасной HTTP/HTTPS-ссылкой проходит, запрещённая private/link-local/metadata ссылка блокируется без HTTP fetch этой ссылки; 4. реальный quarantine file проходит `pending` и terminal result, после allow продвигается штатным caller flow; deny-файл не продвигается; 5. повтор client/idempotency request не создаёт второе сообщение или второй Safety task; 6. correlation request ID виден в VM1, VM2 и SigNoz без текста сообщения, token, object key и других секретов. Затем согласованным способом кратко сделайте VM2 недоступной **только для test request** и подтвердите fail-closed: VM1 не отправляет и не продвигает контент, возвращает контролируемую retryable ошибку, а local/stub fallback не активируется. Сразу восстановите доступ и повторите benign smoke. Не имитируйте отказ остановкой всей VM2, если на ней уже есть другой production traffic. #### 5. Rollback rehearsal Rollback caller — только на заранее проверенный предыдущий schema-compatible immutable release VM1 по [`RUNBOOK.production.ru.md`](../../../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md) §12. Он не меняет nginx VM2, не понижает schema и не удаляет уже созданные Safety tasks: ```sh PREVIOUS='' test -d "/opt/han-chat/releases/${PREVIOUS}/backend" ln -s "releases/${PREVIOUS}" /opt/han-chat/.current-new mv -Tf /opt/han-chat/.current-new /opt/han-chat/current systemctl daemon-reload systemctl restart han-secrets@production.service /opt/han-chat/current/backend/deployment/preflight.sh systemctl restart han-stack@production.service ``` После rehearsal повторите public smoke, верните approved current release тем же атомарным способом и снова повторите smoke. Потеря VM2 не разрешает fail-open, переключение на local stub или обход Safety. При несовместимой migration rollback запрещён: используйте forward fix либо заранее согласованный recovery plan. #### 6. Фиксация cutover Сохраните без secret values: - VM1/VM2 release и image digests, Safety schema head; - private certificate fingerprint/expiry, `config_version` и `rules_version`; - результаты allow/deny/pending/file/timeout/fail-closed/idempotency checks; - firewall counters и доказательство недоступности `8443` с запрещённого source; - traces/log search и результат redaction canary; - фактическое время переключения и rollback rehearsal; - approvals Safety Service, Rule Pack, Security, Product и Operations. Только после успешного выполнения всех пунктов Message Safety cutover считается завершённым. Cutover `bitrix-sync` остаётся отдельным изменением по [`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md). ## Политика отказов - Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать контент. - Устаревшая/недоступная база KESL или ошибка broker отключают только файловую capability; ошибка сканирования никогда не превращается в allow и ведёт к retry/`503`. - Потеря 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. ## Host KESL и broker KESL 12.4 standalone и root-owned fail-closed broker не являются Compose образами. Message Safety worker получает только Unix socket `/run/han-kesl/scan.sock`; доступ к `kesl-control`, Docker socket и host root ему не выдаётся. `scanner_engine=kesl`, а `signatures_version` вычисляется как hash KESL version + database date. Установка, ежечасное обновление, права socket, clean/EICAR/error/stale gates и rollback выполняются строго по [`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).