40 KiB
Ранбук развёртывания Processing на VM2
Этот каталог — независимая основа VM2. Он не разворачивает VM1 и не
затрагивает codebase/backend. Все команды ниже — операторские; создание
репозитория их не выполняет.
Блокеры production перед первым запуском
- Замените каждый плейсхолдер в
.envна проверенные несекретные значения. ДержитеBITRIX_SYNC_ENABLED=false, пока не подписаны миграции, гранты, поля портала, контракты роботов и cutover. (для этого нужно еще образы отправить в conteiner registry, пункт 2) - Заполните каждую переменную
*_IMAGEпроверенным digest из registry. Корневой Compose отклоняет отсутствующие ссылки на образы; изменяемые теги не являются доказательством для production. - Устанавливайте production-файлы от
root:root; пользовательdeployне должен входить в группуdockerи не должен иметь возможность писать Compose, unit-файлы, хелперы, allow-list’ы или маппинги секретов. (смысл: заходим под админом, sudo -i) - Заполните отдельные проверенные активные CIDR-файлы из двух
.template. Их закоммиченные активные версии намеренно содержатdeny all. (в services/nginx/allowlist прописываем разрешенные адреса - адрес ВМ1 и адрес битрикса) - Выпустите публичный ACME-сертификат в host-каталог
/etc/letsencrypt. Разместите CA управляемой PostgreSQL в/etc/han/ca. Выпустите сертификат внутренней CA, SAN которого совпадает с приватным именем VM2. Разрешайте хостовый порт8443только из SG VM1 и, при необходимости, одобренных приватных/VPN-сетей операторов. (выпуск сертификатов) - Создайте отдельный IAM-принципал Selectel для VM2. Он может читать только
имена из
deployment/secrets/config.example.json. Никогда не переиспользуйте принципал VM1. (отдельный проект в селектел, туда отдельного сервисного пользователя с ролью member) REDIS_SAFETY_ACL— полный ACL-файл, а не просто пароль. Он должен открывать неаутентифицированныйPINGтолько для health и защищённого паролем пользователяsafety, ограниченного необходимыми ключами/командамиhan:safety:*. (Пароль вMESSAGE_SAFETY_REDIS_URLдолжен совпадать. Используйтеredis/redis-safety.acl.template, заменив `REPLACE_WITH_LONG_RANDOM_PASSWORD)- Выделите отдельные учётные данные БД для runtime и миграций.
MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URLможет мигрировать/активировать политику, аMESSAGE_SAFETY_DATABASE_URL— нет;BITRIX_SYNC_MIGRATION_DATABASE_URLвладеет DDL, аBITRIX_SYNC_DATABASE_URL— runtime-роль с минимальными привилегиями. Учётные данные миграций монтируются только в jobs профиляops. - 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с GID10001; - стандартный
/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 -t — syntax 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 — нет.