Files
han-app/codebase/services/deployment/RUNBOOK.ru.md
T

40 KiB
Raw Blame History

Ранбук развёртывания Processing на VM2

Этот каталог — независимая основа VM2. Он не разворачивает VM1 и не затрагивает codebase/backend. Все команды ниже — операторские; создание репозитория их не выполняет.

Блокеры 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:*. (Пароль в MESSAGE_SAFETY_REDIS_URL должен совпадать. Используйте redis/redis-safety.acl.template, заменив `REPLACE_WITH_LONG_RANDOM_PASSWORD)
  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 и источников ClamAV. 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. Bootstrap свежей VM2

На локальном компьютере один раз создайте два разных ключа. Закрытые части остаются только у соответствующих операторов и никогда не передаются на VM:

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 каталог:

scp -i C:\Users\MI\.ssh\hansel `
  .\HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh `
  root@<VM2_PUBLIC_IP>:/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@<VM2_PUBLIC_IP>:/root/

На VM2 в текущей root-сессии задайте приватный CIDR VM1. /0 скрипт отклоняет:

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='<PRIVATE_IP_VM1>/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='<OPS_PRIVATE_OR_VPN_CIDR>'.

В текущей root-сессии задайте admin отдельный сложный sudo-пароль. Он не разрешает password SSH: пароль нужен только после входа по admin key:

passwd admin

Не закрывая root-сессию, на локальном компьютере проверьте оба входа:

ssh -i C:\Users\MI\.ssh\han_vm2_deploy deploy@<VM2_PUBLIC_IP>
ssh -i C:\Users\MI\.ssh\han_vm2_admin admin@<VM2_PUBLIC_IP>

В admin-сессии проверьте запрос именно admin-пароля и получение root shell, после чего сразу выйдите из него:

sudo -v
sudo -i
id
exit

Только после успешной проверки deploy, admin и sudo повторите на VM2 под root:

DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='<PRIVATE_IP_VM1>/32' \
HARDEN_SSH=true \
SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh

Это добавит PermitRootLogin no и AllowUsers deploy admin. Ещё раз откройте обе новые SSH-сессии после reload и только затем закрывайте старую root. Публичные bootstrap-копии после проверки можно удалить под admin:

sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub

2. Передача релиза под deploy

На локальном компьютере из каталога HAN_chat_specification:

$Release = "<VERSION_OR_GIT_SHA>"
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@<VM2_PUBLIC_IP>:/var/lib/han-deploy/incoming/

Под deploy на VM2 вычислите checksum. Значение должно совпасть с локальным:

RELEASE='<VERSION_OR_GIT_SHA>'
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 или лишний проект:

RELEASE='<VERSION_OR_GIT_SHA>'
EXPECTED_SHA256='<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 из активного релиза. Приложение всё ещё не запускается:

DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='<PRIVATE_IP_VM1>/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:

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.

Зашифруйте пароль Selectel service user через systemd credentials, не помещая его в аргументы или history:

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 должен оставаться закрытым:

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 установите сертификат вне каталога релиза:

install -d -m 0755 -o root -g root /etc/han/ca
install -m 0644 -o root -g root \
  /tmp/<PROVIDER_POSTGRESQL_CA_FILE> \
  /etc/han/ca/managed-postgresql-ca.pem
openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \
  -noout -subject -issuer -dates
rm -f /tmp/<PROVIDER_POSTGRESQL_CA_FILE>

В .env должно быть:

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 имеет обычный вид:

postgresql+asyncpg://<USER>:<PASSWORD>@<MANAGED_POSTGRES_HOST>:<PORT>/<DATABASE>

Первоначальный выпуск Let's Encrypt

PROCESSING_PUBLIC_HOST должен быть DNS-именем, A-запись которого уже указывает на публичный IP VM2. Сертификат на IP-адрес этим порядком не выпускается. Порт 80 должен быть разрешён в cloud firewall/UFW и пока не занят nginx.

Под root задайте значения только для текущей shell-сессии и подготовьте постоянный webroot:

PUBLIC_HOST='<PROCESSING_PUBLIC_HOST>'
ACME_EMAIL='<ADMIN_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 не использует:

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-сертификат:

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. Preflight, миграции и первый запуск под root

Сначала синхронизируйте секреты. Затем выполните статический preflight:

systemctl start han-secrets-vm2.service
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose config --quiet

До runtime выполните миграции отдельными DB roles и активируйте начальный Message Safety config:

Перед первым bitrix-sync-migrate владелец han_app или администратор БД выдаёт Bitrix migration-role временный read-only доступ к legacy mapping:

GRANT USAGE ON SCHEMA han_app TO <BITRIX_SYNC_MIGRATION_ROLE>;
GRANT SELECT ON TABLE han_app.entity_external_mapping
  TO <BITRIX_SYNC_MIGRATION_ROLE>;
/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>'

После успешного bitrix-sync-migrate администратор БД отзывает временные права. Право USAGE отзывайте только если оно не требуется этой роли для других согласованных операций:

REVOKE SELECT ON TABLE han_app.entity_external_mapping
  FROM <BITRIX_SYNC_MIGRATION_ROLE>;
REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;

Первый запуск и enable выполняет root только после прохождения gates: (внутри gate5)

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:

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

Установка/редактирование unit, Compose, .env, secret mapping, credential, TLS, allow-list и запуск migration jobs остаются операциями root.

Gate 1 — секреты материализованы

Под root на VM2:

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:

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. После них:

/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 без вывода их содержимого:

/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:

/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

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 восстановите контракт файла и пересоздайте затронутые контейнеры:

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; проверьте её без вывода секретов:

/usr/local/sbin/han-vm2-compose logs --tail 100 nginx otel-collector

Дождитесь healthy у сервисов с healthcheck. Не продолжайте при Restarting, unhealthy, OOM или неожиданном Exited. После успешного первого запуска передайте дальнейший lifecycle systemd:

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:

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:

openssl s_client -connect <PROCESSING_PUBLIC_HOST>:443 \
  -servername <PROCESSING_PUBLIC_HOST> \
  -verify_hostname <PROCESSING_PUBLIC_HOST> -verify_return_error </dev/null

С VM1 или ops host, имеющего private route, проверьте internal chain и SAN:

openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
  -servername <VM2_PRIVATE_DNS_NAME> \
  -verify_hostname <VM2_PRIVATE_DNS_NAME> \
  -CAfile <INTERNAL_CA_FILE> -verify_return_error </dev/null

Обе команды должны завершить certificate verification без ошибки.

Переключите renewal с первоначального standalone на webroot, который nginx обслуживает по /.well-known/acme-challenge/. certbot reconfigure сам проверит новый способ через staging CA:

PUBLIC_HOST='<PROCESSING_PUBLIC_HOST>'
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:

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 означает успешный dry-run. Старый hook мог при этом дать ложное Hook 'deploy-hook' ran with error output: Compose писал Killing/Killed, а успешный nginx -tsyntax is ok в stderr. Исправленный hook показывает вывод config test только при ненулевом exit code и использует тихий docker kill --signal HUP. Порт 80 после этого остаётся доступен для HTTP-01 renewal.

Gate 7 — public routing

С внешней тестовой машины:

curl -sS -o /dev/null -w '%{http_code}\n' \
  http://<PROCESSING_PUBLIC_HOST>/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
  https://<PROCESSING_PUBLIC_HOST>/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
  http://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact
curl -sS -o /dev/null -w '%{http_code}\n' \
  https://<PROCESSING_PUBLIC_HOST>/internal/safety/status
curl -sS -o /dev/null -w '%{http_code}\n' -X GET \
  https://<PROCESSING_PUBLIC_HOST>/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:

curl --fail --silent --show-error \
  --cacert <INTERNAL_CA_FILE> \
  https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/status

Benign text check через тот же private listener:

SAFETY_TOKEN="$(cat <MESSAGE_SAFETY_SERVICE_TOKEN_FILE_ON_VM1>)"
MESSAGE_ID="$(uuidgen)"
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <<EOF
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/v2/messages/check"
cacert = "<INTERNAL_CA_FILE>"
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:

BITRIX_TOKEN="$(cat <BITRIX_SYNC_SERVICE_TOKEN_FILE_ON_VM1>)"
curl --silent --show-error --output /tmp/vm2-sync-status.json \
  --write-out '%{http_code}\n' --config - <<EOF
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/sync/v1/status"
cacert = "<INTERNAL_CA_FILE>"
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="HAN_VM2_REDACTION_$(date +%s)"
curl -sS -o /dev/null \
  -H "Authorization: Bearer ${CANARY}" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode "auth[application_token]=${CANARY}" \
  "https://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact?token=${CANARY}"

На VM2 под root:

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.

Политика отказов

  • Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать контент.
  • Устаревшие/недоступные сигнатуры ClamAV отключают только файловую capability; ошибка сканирования никогда не превращается в allow.
  • Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником истины.
  • Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен менять вердикты.
  • Rollback не понижает схемы, не удаляет durable tasks/mappings и не запускает docker compose down -v.

Аварийный MOCK

Разрешены только эти пять sudo-команд:

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 — нет.