Files
han-app/releases/#1.1 VM-service-deploy.md
T

893 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Usefull commands
## Скопировать файл по технологии через архив
cd C:\Users\MI\Documents\Assistent\HAN_chat_specification
$Hotfix = "bug-v15"
tar -czf "vm2-$Hotfix.tar.gz" -C .\codebase `
services/deployment/scripts/setup-vm.sh
Get-FileHash "vm2-$Hotfix.tar.gz" -Algorithm SHA256
scp -i C:\Users\MI\.ssh\han_vm2_deploy `
"vm2-$Hotfix.tar.gz" `
deploy@135.106.179.209:/var/lib/han-deploy/incoming/
services/docker-compose.yml
HAN_chat_specification\codebase\services\docker-compose.yml
HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh
HOTFIX='bug-v15'
EXPECTED_SHA256='BF85F75F45F63BFB8310C6E0B835276DE7EBF316180120F6383590C19E44FEE4'
ARCHIVE="/var/lib/han-deploy/incoming/vm2-${HOTFIX}.tar.gz"
printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check -
tar -tzf "$ARCHIVE"
STAGING="$(mktemp -d /opt/han-chat/.nginx-hotfix.XXXXXX)"
tar -xzf "$ARCHIVE" -C "$STAGING" --no-same-owner --no-same-permissions
install -m 0644 -o root -g root "$STAGING/services/deployment/scripts/setup-vm.sh" /opt/han-chat/services/deployment/scripts/setup-vm.sh
sed -i 's/\r$//' ./deployment/scripts/setup-vm.sh
find -type f -exec file {} \; | grep -i 'CRLF'
## Перезапустить контейнер
/usr/local/sbin/han-vm2-compose up -d --force-recreate freshclam
# Создание докер-образов
docker login cr.selcloud.ru
cd /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/services/bitrix-sync
docker build -t han-bitrix-sync:1.0.3 .
REGISTRY=cr.selcloud.ru/han-images
docker tag han-bitrix-sync:1.0.3 $REGISTRY/han-bitrix-sync:1.0.3
docker push $REGISTRY/han-bitrix-sync:1.0.3
docker image inspect $REGISTRY/han-bitrix-sync:1.0.3 --format '{{index .RepoDigests 0}}'
cd /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/services/message-safety
docker build -t han-message-safety:1.0.1 .
REGISTRY=cr.selcloud.ru/han-images # ваш registry
docker tag han-message-safety:1.0.1 $REGISTRY/han-message-safety:1.0.1
docker push $REGISTRY/han-message-safety:1.0.1
docker image inspect $REGISTRY/han-message-safety:1.0.1 --format '{{index .RepoDigests 0}}'
## Чтобы собрать sha256 с остальных образов, их надо закачать
docker pull nginxinc/nginx-unprivileged:1.27
docker pull redis:7.4
docker pull clamav/clamav:1.4
docker pull otel/opentelemetry-collector-contrib:0.117.0
docker image inspect nginxinc/nginx-unprivileged:1.27 --format '{{index .RepoDigests 0}}'
docker image inspect redis:7.4 --format '{{index .RepoDigests 0}}'
docker image inspect clamav/clamav:1.4 --format '{{index .RepoDigests 0}}'
docker image inspect otel/opentelemetry-collector-contrib:0.117.0 --format '{{index .RepoDigests 0}}'
# Генерация ключей для новых пользователей и первичная настройка ВМ2
ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_deploy -C "han-vm2-deploy"
ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_admin -C "han-vm2-break-glass-admin"
scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\Documents\Assistent\HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh root@135.106.179.209:/root/setup-vm2.sh
scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\.ssh\han_vm2_deploy.pub C:\Users\MI\.ssh\han_vm2_admin.pub root@135.106.179.209:/root/
На VM2 в текущей root-сессии задайте реальные CIDR. `/0` скрипт отклоняет:
```sh
ssh -i C:\Users\MI\.ssh\hansel root@135.106.179.209
sed -i 's/\r$//' /root/setup-vm2.sh
install -d -m 0700 -o root -g root /root/bootstrap
install -m 0600 -o root -g root /root/han_vm2_deploy.pub /root/bootstrap/deploy.pub
install -m 0600 -o root -g root /root/han_vm2_admin.pub /root/bootstrap/admin.pub
chmod 0700 /root/setup-vm2.sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='192.168.0.0/24' \
/root/setup-vm2.sh
```
1 В текущей root-сессии задайте `admin` отдельный сложный sudo-пароль. Он не
разрешает password SSH: пароль нужен только после входа по admin key:
```sh
passwd admin
```
2 Не закрывая root-сессию, проверьте отдельные SSH-ключи deploy и admin.
ssh -i C:\Users\MI\.ssh\han_vm2_deploy deploy@135.106.179.209
ssh -i C:\Users\MI\.ssh\han_vm2_admin admin@135.106.179.209
В сессии admin проверьте sudo -v и sudo -i, затем завершите root shell:
sudo -v # проверить, что пароль admin принимается
sudo -i # открыть root shell (приглашение обычно root@...)
id # убедиться, что uid=0
exit # ← вот это «завершите root shell» — вернуться к admin@
# Только после успешной проверки `deploy`, `admin` и `sudo` повторите на VM2
под `root`:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='192.168.0.0/24' \
HARDEN_SSH=true \
SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh
```
Это добавит `PermitRootLogin no` и `AllowUsers deploy admin`.
Публичные bootstrap-копии после проверки можно удалить под `admin`:
```sh
sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub
```
# Проверка наличия сетевого интерфейса для ВМ и его создание
ls -la /etc/netplan
netplan get
ip route
cat >/etc/netplan/60-private-network.yaml <<'EOF'
network:
version: 2
ethernets:
eth1:
dhcp4: false
addresses:
- 192.168.0.4/24
optional: true
EOF
chmod 0600 /etc/netplan/60-private-network.yaml
netplan generate
netplan try
Подтвердите конфигурацию в течение тайм-аута. Затем проверьте:
ip -br -4 address show eth1
ip route
ping -c 3 192.168.0.1
# nginx правим разрешенные адреса
nginx/allowlists/
# Копируем проект
(Текущая команда копирует весь каталог ради простоты формирования единого архива, но технической необходимости в исходниках приложений нет)
На локальном компьютере из каталога `HAN_chat_specification`:
```powershell
$Release = "1.0.3"
tar --exclude=services/.env `
--exclude='services/**/__pycache__' `
--exclude='services/**/.pytest_cache' `
--exclude='services/**/.ruff_cache' `
-czf "vm2-services-$Release.tar.gz" -C C:\Users\MI\Documents\Assistent\HAN_chat_specification\codebase services
Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256
scp -i C:\Users\MI\.ssh\han_vm2_deploy "vm2-services-$Release.tar.gz" `
deploy@135.106.179.209:/var/lib/han-deploy/incoming/
```
Под `deploy` на VM2 вычислите checksum. Значение должно совпасть с локальным:
```sh
RELEASE='1.0.2'
cd /var/lib/han-deploy/incoming
sha256sum "vm2-services-${RELEASE}.tar.gz"
tar -tzf "vm2-services-${RELEASE}.tar.gz"
```
На этом действия `deploy` с файлами заканчиваются. Не распаковывайте релиз
через `sudo` и не копируйте его в production от имени `deploy`.
# Активация и установка файлов под `root`
ssh -i C:\Users\MI\.ssh\han_vm2_admin admin@135.106.179.209
sudo -i
id
Должно быть uid=0(root).
Под `root` ещё раз сверьте ожидаемый SHA-256 и список архива. Не продолжайте,
если архив содержит абсолютные пути, `..`, symlink/hardlink или лишний проект:
```sh
RELEASE='1.0.3'
EXPECTED_SHA256='0FC8F2B85EC39EE41BD6D3E3789963A4D985B3E2E9638E50BEAF978EA6A46271'
ARCHIVE="/var/lib/han-deploy/incoming/vm2-services-${RELEASE}.tar.gz"
printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check -
tar -tvzf "$ARCHIVE"
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then
echo 'ОШИБКА: архив содержит небезопасный путь или .env' >&2
exit 1
fi
if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then
echo 'ОШИБКА: архив содержит файлы вне каталога services' >&2
exit 1
fi
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ { found=1 } END { exit !found }'; then
echo 'ОШИБКА: архив содержит symlink или hardlink' >&2
exit 1
fi
install -d -m 0755 -o root -g root /opt/han-chat/services
STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)"
tar --extract --gzip --file "$ARCHIVE" \
--directory "$STAGING" --no-same-owner --no-same-permissions
test -f "$STAGING/services/docker-compose.yml"
rsync -a --delete --exclude=.env \
--chown=root:root --chmod=D755,F644 \
"$STAGING/services/" /opt/han-chat/services/
rm -rf -- "$STAGING"
chmod 0755 /opt/han-chat/services/deployment/preflight.sh
```
cd /opt/han-chat/services/
sudo apt update
sudo apt install -y dos2unix
find . -type f \( \
-name '*.sh' -o -name '*.py' -o -name '*.service' -o -name '*.sudoers' -o -name 'han-compose' -o -name 'han-secrets' -o -name 'han-message-safety-mode' -o -name '*.yml' -o -name '*.conf*' -o -name 'preflight.sh' -o -name '*.yaml' -o -name '*.md' -o -name '*.toml' -o -name 'Dockerfile' -o -name '*.acl*' \
\) -exec dos2unix {} +
find -type f -exec file {} \; | grep -i 'CRLF'
sudo apt remove -y dos2unix
sudo apt purge -y dos2unix
# Если перезалили
scp /opt/han-chat/services/deployment/scripts/setup-vm.sh /root/setup-vm2.sh
chmod 0700 /root/setup-vm2.sh
Была проблема с каретками, полечилась так:
(sed -i 's/\r$//' \
/opt/han-chat/services/deployment/deploy-message-safety-mode.sudoers
install -m 0440 -o root -g root \
/opt/han-chat/services/deployment/deploy-message-safety-mode.sudoers \
/etc/sudoers.d/deploy-message-safety-mode
visudo -cf /etc/sudoers.d/deploy-message-safety-mode)
Повторите setup под `root`: теперь он установит helpers и units из активного
релиза. Приложение всё ещё не запускается:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='192.168.0.0/24' \
HARDEN_SSH=true \
SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh
```
# Несекретная конфигурация и Selectel под `root`
`APP_ENV` определяет имя loader config. При значении из `.env.example`
`APP_ENV=production-like` файл обязан называться
`/etc/han/secrets/vm2-production-like.selectel.json`:
```sh
cd /opt/han-chat/services
install -m 0600 -o root -g root .env.example .env
editor .env
# я скопировал и вставил значения из локального
sed -i 's/\r$//' .env
install -m 0600 -o root -g root \
deployment/secrets/config.example.json \
/etc/han/secrets/vm2-production-like.selectel.json
editor /etc/han/secrets/vm2-production-like.selectel.json
```
sudo sed -i 's/\r$//' /etc/han/secrets/vm2-production-like.selectel.json
ls /etc/han/secrets/vm2-production-like.selectel.json
# Для заполнения секретов нужно создать сертификаты для ВМ1-ВМ2.
### 1. Создать CA (один раз)
На безопасной машине (не обязательно VM2):
```bash
mkdir -p ~/han-internal-ca && cd ~/han-internal-ca
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-subj "/CN=HAN Internal CA" \
-out ca.crt
```
`ca.key` храните только у себя; на ВМ не кладите.
### 2. Выпустить серверный сертификат VM2
```bash
openssl genrsa -out vm2-processing.key 2048
cat > vm2-processing.ext <<'EOF'
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = processing.internal
EOF
openssl req -new -key vm2-processing.key \
-subj "/CN=processing.internal" \
-out vm2-processing.csr
openssl x509 -req -in vm2-processing.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out vm2-processing.crt -days 825 -sha256 \
-extfile vm2-processing.ext
```
Проверка:
```bash
openssl x509 -in vm2-processing.crt -noout -text | grep -A2 'Subject Alternative Name'
```
### 3. Разложить секреты
**VM2** (в Selectel Secrets / файлы loader’а):
- `VM2_INTERNAL_TLS_CERTIFICATE` ← содержимое `vm2-processing.crt` (можно fullchain: cert + ca.crt)
- `VM2_INTERNAL_TLS_PRIVATE_KEY``vm2-processing.key`
**VM1**:
scp -i ~/.ssh/hansel ./han-internal-ca/ca.crt root@135.106.164.58:/tmp/ca.crt
На VM1 от root:
install -d -o root -g root -m 0755 /opt/han-chat/backend/secrets/tls
install -o root -g root -m 0644 /tmp/ca.crt \
/opt/han-chat/backend/secrets/tls/processing-internal-ca.crt
rm -f /tmp/ca.crt
ls /opt/han-chat/backend/secrets/tls/processing-internal-ca.crt
nano /opt/han-chat/backend/.env
MESSAGE_SAFETY_URL=https://processing.internal:8443
MESSAGE_SAFETY_CA_HOST_PATH=/opt/han-chat/backend/secrets/tls/processing-internal-ca.crt
# PRIVATE NETWORK
В private DNS / hosts на VM1:
echo '192.168.0.4 processing.internal' >> /etc/hosts
Зашифруйте пароль Selectel service user через systemd credentials, не помещая
его в аргументы или history:
```sh
read -rsp 'Selectel VM2 service-user password: ' SELECTEL_PASSWORD; echo
printf '%s' "$SELECTEL_PASSWORD" | systemd-creds encrypt \
--name=selectel-service-user-password - \
/etc/han/credentials/vm2.selectel-password.cred
unset SELECTEL_PASSWORD
chown root:root /etc/han/credentials/vm2.selectel-password.cred
chmod 0600 /etc/han/credentials/vm2.selectel-password.cred
```
Активные nginx allow-list файлы редактирует только `root`; последней строкой
обязательно остаётся `deny all;`. До cutover Bitrix public allow-list должен
оставаться закрытым:
```sh
editor /opt/han-chat/services/nginx/allowlists/private-caller-allowlist.conf
install -m 0644 -o root -g root \
/opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf.template \
/opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf
chown root:root /opt/han-chat/services/nginx/allowlists/*.conf
chmod 0644 /opt/han-chat/services/nginx/allowlists/*.conf
```
Нужно заполнить **два активных nginx allow-list** на VM2 (не UFW из `setup-vm.sh`). Шаблоны лежат в `nginx/allowlists/`.
### 1. `private-caller-allowlist.conf` — обязательно для работы Safety
Кто может ходить на **private API `:8443`** (VM1 → Message Safety / sync status).
Пример:
```nginx
# allow приватный IP VM1
allow 192.168.0.10/32;
# опционально — ваш VPN/ops в private сети
# allow 192.168.0.50/32;
deny all;
```
Берёте приватный IP VM1 (тот же смысл, что `VM1_PRIVATE_CIDRS`). Последняя строка всегда `deny all;`.
### 2. `bitrix-webhook-allowlist.conf` — только перед включением Bitrix
Кто может бить в **публичные webhook** (`/bitrix/sync/webhook/...`).
До cutover ранбук говорит: **оставить закрытым** (`deny all;` без `allow`) — это нормально, пока `BITRIX_SYNC_ENABLED=false`.
Перед включением sync — IP/CIDR исходящих адресов Битрикс24 (или прокси), например:
```nginx
allow 185.x.x.x/32;
deny all;
```
### Где править
На VM2 после раскладки релиза:
```text
/opt/han-chat/services/nginx/allowlists/private-caller-allowlist.conf
/opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf
```
Шаблоны-подсказки: `*.conf.template`. Активные `.conf` в git намеренно с одним `deny all;` — заполняете на сервере (или копируете из template и раскомментируете `allow`).
Это **отдельный слой от UFW**: UFW режет порт на хосте, nginx allow-list — кто дойдёт до location после TLS.
# Сертификат PostgreSQL и первоначальный выпуск public TLS
### CA управляемой PostgreSQL
Скачайте CA-сертификат кластера из панели провайдера и передайте его на VM2 во
временный путь. Под `root` установите сертификат вне каталога релиза:
scp -i C:\Users\MI\.ssh\han_vm2_deploy -r C:\Users\MI\Documents\job\HAN_new_life\HANapp\Production\sertificates\CA.pem deploy@135.106.179.209:/tmp/ca.pem
```sh
install -d -m 0755 -o root -g root /etc/han/ca
install -m 0644 -o root -g root \
/tmp/ca.pem \
/etc/han/ca/managed-postgresql-ca.pem
openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \
-noout -subject -issuer -dates
rm -f /tmp/ca.pem
```
# Первоначальный выпуск Let's Encrypt
`PROCESSING_PUBLIC_HOST` должен быть DNS-именем, A-запись которого уже указывает
на публичный IP VM2. Сертификат на IP-адрес этим порядком не выпускается. Порт
`80` должен быть разрешён в cloud firewall/UFW и пока не занят nginx.
Под `root` задайте значения только для текущей shell-сессии и подготовьте
постоянный webroot:
```sh
PUBLIC_HOST=service4chat.han0107.ru
ACME_EMAIL=ap@han.ru
install -d -m 0755 -o root -g root /var/lib/han-chat/acme
getent ahostsv4 "$PUBLIC_HOST"
ss -lntp | grep -E ':80[[:space:]]' && {
echo 'Порт 80 уже занят; остановите listener перед standalone-проверкой' >&2
exit 1
} || true
```
Сначала проверьте ACME через staging CA. Этот сертификат nginx не использует:
```sh
certbot certonly --standalone --preferred-challenges http \
--staging \
-d "$PUBLIC_HOST" \
--cert-name "${PUBLIC_HOST}-staging" \
--email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
certbot delete --cert-name "${PUBLIC_HOST}-staging" --non-interactive
```
После успешного staging-теста выпустите production-сертификат:
```sh
certbot certonly --standalone --preferred-challenges http \
-d "$PUBLIC_HOST" \
--cert-name "$PUBLIC_HOST" \
--email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
certbot certificates
test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem"
test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem"
```
Nginx получает `/etc/letsencrypt` с host read-only и после этого может пройти
Gate 4 и первый запуск. Не копируйте private key в каталог релиза.
# Preflight, миграции и первый запуск под `root`
Сначала синхронизируйте секреты. Затем выполните статический preflight:
```sh
systemctl start han-secrets-vm2.service
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose config --quiet
```
для перезапуска секретов
systemctl restart han-secrets-vm2.service
systemctl show han-secrets-vm2.service -p ActiveState -p SubState -p Result -p ExecMainStartTimestamp -p ExecMainStatus
Перед первым `bitrix-sync-migrate` владелец `han_app` или администратор БД
выдаёт Bitrix migration-role временный read-only доступ к legacy mapping:
```sql
GRANT USAGE ON SCHEMA han_app TO bitrix_sync_user;
GRANT SELECT ON TABLE han_app.entity_external_mapping
TO bitrix_sync_user;
До runtime выполните миграции отдельными DB roles и активируйте начальный
Message Safety config:
```sh
/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml --version 1 --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version 1 --approved-by '<APPROVER>'
```
Потом права отзываем
REVOKE SELECT ON TABLE han_app.entity_external_mapping
FROM bitrix_sync_user;
REVOKE USAGE ON SCHEMA han_app FROM bitrix_sync_user;
Первый запуск и enable выполняет `root` только после прохождения gates:
Установка/редактирование unit, Compose, `.env`, secret mapping, credential,
TLS, allow-list и запуск migration jobs остаются операциями `root`.
# GATES
### Gate 1 — секреты материализованы
Под `root` на VM2:
```sh
systemctl restart han-secrets-vm2.service
systemctl is-active han-secrets-vm2.service
journalctl --no-pager -u han-secrets-vm2.service
test -s /run/han-chat/secrets/manifest
cut -d= -f1 /run/han-chat/secrets/manifest | sort
```
Ожидается `active`; журнал не содержит значений секретов; последняя команда
показывает только имена всех ключей из mapping. Не выполняйте `cat` файлов
секретов и не вставляйте реальные значения в terminal history.
### Gate 2 — статический preflight и Compose
Под `root` на VM2:
```sh
cd /opt/han-chat/services
deployment/preflight.sh
/usr/local/sbin/han-vm2-compose config --quiet
/usr/local/sbin/han-vm2-compose config --services
/usr/local/sbin/han-vm2-compose config --images
```
Все команды должны завершиться с кодом `0`. В списке services нет PostgreSQL,
а все production images содержат `@sha256:`. Вывод полного resolved Compose в
файл не сохраняйте.
### Gate 3 — миграции и активный Message Safety config
Команды миграций из предыдущего раздела выполняются под `root`. После них:
```sh
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
message-safety-migrate current
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
bitrix-sync-migrate current
```
Ожидается по одной head revision каждого сервиса. `create --version 1`
выполняется только при первом развёртывании. Для следующего конфига используйте
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
активировать старую версию нельзя. Alembic downgrade запрещён.
### Gate 4 — конфигурация nginx до запуска
После выпуска public TLS в `/etc/letsencrypt` и материализации internal TLS
secrets. В Selectel значения `VM2_INTERNAL_TLS_CERTIFICATE` и
`VM2_INTERNAL_TLS_PRIVATE_KEY` сохраняются как исходный PEM с настоящими
переводами строк, не как повторный base64 и не как строка с литералами `\n`.
После изменения remote secret перезапустите `han-secrets-vm2.service`; preflight
проверит формат PEM и соответствие certificate/key без вывода их содержимого:
```sh
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose run --rm --no-deps \
-e MESSAGE_SAFETY_UPSTREAM_HOST=127.0.0.1 \
-e BITRIX_SYNC_UPSTREAM_HOST=127.0.0.1 \
nginx \
nginx -t -c /etc/nginx/nginx.conf
```
Базовый `nginx.conf` подключает обязательный
`/etc/nginx/conf.d/10-vm2.conf`, поэтому команда завершится ошибкой, если
entrypoint не создал конфигурацию из шаблона. Временные значения upstream
нужны только для проверки до первого запуска backend-контейнеров; production
Compose подставляет DNS-имена сервисов. Ожидается `syntax is ok` и `test is
successful`; ошибок `conf.d is not writable` и предупреждения о превышении
open-file limit быть не должно. Ошибка отсутствующего сертификата является
блокером, а не основанием временно убрать TLS.
### Gate 5 — упорядоченный первый запуск
Под `root` на VM2:
```sh
/usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector
/usr/local/sbin/han-vm2-compose up -d freshclam clamd
/usr/local/sbin/han-vm2-compose up -d \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose up -d \
bitrix-sync bitrix-sync-worker bitrix-sync-reconciliation
/usr/local/sbin/han-vm2-compose up -d nginx
/usr/local/sbin/han-vm2-compose ps
```
Дождитесь `healthy` у сервисов с healthcheck. Не продолжайте при
`Restarting`, `unhealthy`, OOM или неожиданном `Exited`. После успешного
первого запуска передайте дальнейший lifecycle systemd:
```sh
systemctl enable han-secrets-vm2.service han-processing.service
systemctl start han-processing.service
systemctl --no-pager status han-processing.service
```
### Gate 6 — host ports и сертификаты
Под `root` на VM2:
```sh
ss -lntp | grep -E ':(80|443|8443|6379|8080|4317|4318)[[:space:]]'
/usr/local/sbin/han-vm2-compose ps --format json | jq .
```
Ожидаются host listeners только nginx: public `80`, `443` и private-bound
`8443` на `PROCESSING_PRIVATE_BIND_ADDRESS`. `6379`, container `8080` и OTLP
`4317/4318` на host отсутствуют.
С доверенной рабочей станции проверьте public chain:
```sh
openssl s_client -connect service4chat.han0107.ru:443 \
-servername service4chat.han0107.ru \
-verify_hostname service4chat.han0107.ru -verify_return_error </dev/null
```
С VM1 или ops host, имеющего private route, проверьте internal chain и SAN:
```sh
openssl s_client -connect 192.168.0.4:8443 \
-servername processing.internal \
-verify_hostname processing.internal \
-CAfile /opt/han-chat/backend/secrets/tls/processing-internal-ca.crt \
-verify_return_error </dev/null
```
Обе команды должны завершить certificate verification без ошибки.
Переключите renewal с первоначального `standalone` на webroot, который nginx
обслуживает по `/.well-known/acme-challenge/`. `certbot reconfigure` сам
проверит новый способ через staging CA:
```sh
PUBLIC_HOST='service4chat.han0107.ru'
certbot reconfigure \
--cert-name "$PUBLIC_HOST" \
--authenticator webroot \
--webroot-path /var/lib/han-chat/acme
```
Повторный setup после активации релиза устанавливает deploy-hook
`/etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx`: после успешного
обновления он атомарно размещает certificate/key с группой `han-nginx-tls` в
`/var/lib/han-chat/public-tls`, проверяет конфигурацию nginx и отправляет
контейнеру `HUP`.
Проверьте полный цикл и включите штатное расписание Certbot:
```sh
test -x /etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx
certbot renew --dry-run --run-deploy-hooks
systemctl enable --now certbot.timer
systemctl --no-pager status certbot.timer
systemctl list-timers certbot.timer
```
`certbot.timer` проверяет необходимость продления дважды в сутки; сертификат
перевыпускается только при приближении срока. Ошибка dry-run или deploy-hook —
блокер. Итог `Congratulations, all simulated renewals succeeded` означает
успех. Со старым hook сообщение `Hook 'deploy-hook' ran with error output`
могло быть ложным: Compose писал успешный `HUP` как `Killing/Killed`, а
успешный `nginx -t``syntax is ok` в stderr. Исправленный hook выводит
config-test только при ошибке и отправляет HUP без progress-вывода. Порт `80`
после этого остаётся доступен для HTTP-01 renewal.
### Gate 7 — public routing
С внешней тестовой машины:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' \
http://service4chat.han0107.ru/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
https://service4chat.han0107.ru/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
http://service4chat.han0107.ru/bitrix/sync/webhook/contact
curl -sS -o /dev/null -w '%{http_code}\n' \
https://service4chat.han0107.ru/internal/safety/status
curl -sS -o /dev/null -w '%{http_code}\n' -X GET \
https://service4chat.han0107.ru/bitrix/sync/webhook/contact
```
Ожидаемые коды по порядку: `308`, `404`, `426`, `404`, `405`. Для HTTPS
используйте только валидный public certificate, без `-k`.
POST к webhook с адреса вне Bitrix allow-list должен получить `403`; если
cloud firewall настроен на drop, допустим timeout. Затем повторите с
разрешённого source IP и заведомо неверным receiver token: upstream должен
ответить `403`, не `2xx`.
### Gate 8 — private API только с VM1/ops
Следующие команды выполняются **на VM1** или approved ops host, не на VM2.
Используйте private DNS/SAN и внутреннюю CA.
Safety status:
```sh
curl --fail --silent --show-error \
--cacert /opt/han-chat/backend/secrets/tls/processing-internal-ca.crt \
https://processing.internal:8443/internal/safety/status
```
Benign text check через тот же private listener:
```sh
SAFETY_TOKEN="$(cat /run/han-chat/secrets/MESSAGE_SAFETY_SERVICE_TOKEN)"
MESSAGE_ID="$(uuidgen)"
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <<EOF
url = "https://processing.internal:8443/internal/safety/v2/messages/check"
cacert = "/opt/han-chat/backend/secrets/tls/processing-internal-ca.crt"
request = "POST"
header = "X-Service-Token: ${SAFETY_TOKEN}"
header = "Content-Type: application/json"
data = "{\"message_id\":\"${MESSAGE_ID}\",\"content_kind\":\"text\",\"text\":\"VM2 safety canary\",\"attachment\":null}"
EOF
unset SAFETY_TOKEN MESSAGE_ID
```
В standard mode ожидается `HTTP 200`, `verdict=allow` и непустые
`config_version`/`rules_version`. Проверку `202 → Location → task GET`
выполняйте отдельным file smoke только с реальным versioned quarantine object:
выдуманные S3 key/version/ETag не являются валидным тестом.
Для Bitrix status прочитайте token из уже защищённого secret file VM1 в
переменную и передайте curl config через stdin, чтобы значение не попало в
argv/history:
```sh
BITRIX_TOKEN="$(cat /run/han-chat/secrets/BITRIX_SYNC_SERVICE_TOKEN)"
curl --silent --show-error --output /tmp/vm2-sync-status.json \
--write-out '%{http_code}\n' --config - <<EOF
url = "https://processing.internal:8443/internal/sync/v1/status"
cacert = "/opt/han-chat/backend/secrets/tls/processing-internal-ca.crt"
header = "Authorization: Bearer ${BITRIX_TOKEN}"
EOF
unset BITRIX_TOKEN
cat /tmp/vm2-sync-status.json
rm -f /tmp/vm2-sync-status.json
```
При `BITRIX_SYNC_ENABLED=false` ожидается закрытая/неготовая синхронизация, а
не ложный успешный full-mode status. С машины вне `VM1_PRIVATE_CIDRS` и
необязательных приватных/VPN-сетей `OPS_CIDRS` подключение к `8443` должно
завершиться timeout/reject.
### Gate 9 — canary на отсутствие секретов в логах и traces
Создайте **фейковый**, не production token marker и отправьте его с
разрешённого тестового source IP:
CANARY можно ставить любой
```sh
CANARY="HAN_VM2_REDACTION_260813"
curl -sS -o /dev/null \
-H "Authorization: Bearer ${CANARY}" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode "auth[application_token]=${CANARY}" \
"https://service4chat.han0107.ru/bitrix/sync/webhook/contact?token=${CANARY}"
```
На VM2 под `root`:
```sh
CANARY='HAN_VM2_REDACTION_260813'
if /usr/local/sbin/han-vm2-compose logs --no-color \
nginx bitrix-sync message-safety-api otel-collector |
grep -F -- "$CANARY"; then
echo 'FAIL: canary попал в логи' >&2
exit 1
fi
unset CANARY
```
В SigNoz выполните поиск этого же marker по logs и span attributes за окно
теста: результат должен быть пустым. Отдельными фейковыми markers повторите
проверку для DSN-подобной строки, S3 key и object key. Реальные secrets для
такой проверки не используйте.
Не открывайте webhook-трафик, пока `bitrix-sync` отключён. Отключённый или
упавший receiver должен возвращать retryable `503`/закрытую маршрутизацию,
никогда успешный `2xx ignored`.
## Политика отказов
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
контент.
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
capability; ошибка сканирования никогда не превращается в allow.
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
истины.
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
менять вердикты.
- Rollback не понижает схемы, не удаляет durable tasks/mappings и не
запускает `docker compose down -v`.
## Аварийный MOCK
Разрешены только эти пять sudo-команд:
```text
han-message-safety-mode standard
han-message-safety-mode mock --text-free true --file-free true
han-message-safety-mode mock --text-free true --file-free false
han-message-safety-mode mock --text-free false --file-free true
han-message-safety-mode mock --text-free false --file-free false
```
Хелпер атомарно пишет только
`/etc/han-chat/message-safety-mode.env`, пересоздаёт только Safety API,
проверяет health и при сбое восстанавливает предыдущий режим. У MOCK нет
таймаута: держите high-severity alert активным до явного `standard`, затем
проверьте нормальные text/link/file capabilities и EICAR-canary.
## Известные исключения по образам
Образы ClamAV могут потребовать корректировок UID/path после валидации
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
задокументируйте минимальные writable пути для сигнатур/runtime и
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
получает только `freshclam`; `clamd` — нет.
Проверка отправки в Сигноз
docker network ls | grep observability
docker run --rm --network han-processing_observability \
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
traces --otlp-endpoint otel-collector:4317 --otlp-insecure \
--service han-processing-otlp-smoke --traces 100 --rate 20
Проверка
/usr/local/sbin/han-vm2-compose logs --since=5m otel-collector |
grep -Ei 'error|refused|queue is full|Unauthenticated|tls' || echo 'нет ошибок экспорта'
после успешной проверки нужно удалить
docker rmi ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"