1144 lines
57 KiB
Markdown
1144 lines
57 KiB
Markdown
# Ранбук развёртывания 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 и
|
||
источников 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)
|
||
|
||
Этот 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,
|
||
clamav, otel-collector, Redis exporter, nginx exporter).
|
||
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@<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` скрипт отклоняет:
|
||
|
||
```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='<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:
|
||
|
||
```sh
|
||
passwd admin
|
||
```
|
||
|
||
Не закрывая root-сессию, на локальном компьютере проверьте оба входа:
|
||
|
||
```powershell
|
||
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,
|
||
после чего сразу выйдите из него:
|
||
|
||
```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='<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`:
|
||
|
||
```sh
|
||
sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub
|
||
```
|
||
|
||
## 2. Передача релиза под `deploy`
|
||
|
||
На локальном компьютере из каталога `VM2_services`:
|
||
|
||
```powershell
|
||
$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. Значение должно совпасть с локальным:
|
||
|
||
```sh
|
||
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 или лишний проект:
|
||
|
||
```sh
|
||
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 из активного
|
||
релиза. Приложение всё ещё не запускается:
|
||
|
||
```sh
|
||
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`:
|
||
|
||
```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/<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` должно быть:
|
||
|
||
```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://<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:
|
||
|
||
```sh
|
||
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 не использует:
|
||
|
||
```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 <BITRIX_SYNC_MIGRATION_ROLE>;
|
||
GRANT SELECT ON TABLE han_app.entity_external_mapping
|
||
TO <BITRIX_SYNC_MIGRATION_ROLE>;
|
||
```
|
||
|
||
```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>'
|
||
```
|
||
|
||
После успешного `bitrix-sync-migrate` администратор БД отзывает временные
|
||
права. Право `USAGE` отзывайте только если оно не требуется этой роли для
|
||
других согласованных операций:
|
||
|
||
```sql
|
||
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>;
|
||
```
|
||
|
||
Проверьте 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 запрещён.
|
||
|
||
При обновлении ClamAV 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 '<OPERATOR>'
|
||
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
|
||
--entrypoint message-safety-config message-safety-migrate \
|
||
activate --version "$NEXT_VERSION" --approved-by '<APPROVER>'
|
||
/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 clamd freshclam
|
||
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
|
||
/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`
|
||
восстановите контракт файла и пересоздайте затронутые контейнеры:
|
||
|
||
```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 <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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```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` означает
|
||
успешный 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
|
||
|
||
С внешней тестовой машины:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
curl --fail --silent --show-error \
|
||
--cacert <INTERNAL_CA_FILE> \
|
||
https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/status
|
||
```
|
||
|
||
Benign text check через тот же private listener:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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`:
|
||
|
||
```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;
|
||
- возраст ClamAV signatures;
|
||
- OTEL queue/export errors;
|
||
- disk/RAM;
|
||
- активный MOCK mode;
|
||
- недоступность private Safety API.
|
||
|
||
5. Далее — отдельный controlled cutover Message Safety на VM1: private URL, internal CA, service token, API integration, rollback rehearsal и функциональные проверки.
|
||
|
||
6. `bitrix-sync` пока оставить:
|
||
|
||
```dotenv
|
||
BITRIX_SYNC_ENABLED=false
|
||
BITRIX_SYNC_MODE=disabled
|
||
```
|
||
|
||
Public allow-list — только `deny all;`. Включать Bitrix можно лишь после
|
||
выполнения gates [`module-07-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 <VM2_PRIVATE_IP>:8443 \
|
||
-servername <VM2_PRIVATE_DNS_NAME> \
|
||
-verify_hostname <VM2_PRIVATE_DNS_NAME> \
|
||
-CAfile /etc/han/ca/vm2-internal-ca.pem \
|
||
-verify_return_error </dev/null
|
||
|
||
curl --fail --silent --show-error \
|
||
--cacert /etc/han/ca/vm2-internal-ca.pem \
|
||
https://<VM2_PRIVATE_DNS_NAME>: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_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 = "/etc/han/ca/vm2-internal-ca.pem"
|
||
request = "POST"
|
||
header = "X-Service-Token: ${SAFETY_TOKEN}"
|
||
header = "X-Request-ID: ${MESSAGE_ID}"
|
||
header = "Content-Type: application/json"
|
||
data = "{\"message_id\":\"${MESSAGE_ID}\",\"content_kind\":\"text\",\"text\":\"VM1 to VM2 cutover canary\",\"attachment\":null}"
|
||
EOF
|
||
unset SAFETY_TOKEN MESSAGE_ID
|
||
```
|
||
|
||
Ожидается `HTTP 200`, `verdict=allow`, `processing_mode=standard` и непустые
|
||
`config_version`/`rules_version`. Отдельный запрос с фейковым token marker
|
||
должен вернуть `401`; production token для negative test не изменяйте.
|
||
|
||
До переключения также выполните через API VM2:
|
||
|
||
- deny smoke на утверждённом безопасном corpus case — ожидается `403`;
|
||
- повтор запроса с тем же `message_id` и тем же body — тот же sticky result;
|
||
- тот же `message_id` с другим body — `409`;
|
||
- file smoke только с реальным versioned quarantine object:
|
||
`202 + Location + Retry-After`, затем terminal `200` или `403`;
|
||
- lease/fencing smoke с остановкой/возвратом worker по утверждённому test case:
|
||
task не исполняется двумя владельцами и сохраняет sticky terminal result.
|
||
|
||
Не используйте выдуманные S3 key/version/ETag и не загружайте EICAR в
|
||
production bucket вне согласованного security test.
|
||
|
||
#### 3. Переключение caller на VM1
|
||
|
||
На VM1 установите и проверьте internal CA по процедуре
|
||
[`RUNBOOK.production.ru.md`](../../../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md)
|
||
§6. В `/etc/han/vm1.env` должны быть:
|
||
|
||
```dotenv
|
||
MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS_NAME>: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='<PREVIOUS_COMPATIBLE_GIT_SHA>'
|
||
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 не должна отправлять/продвигать
|
||
контент.
|
||
- Устаревшие/недоступные сигнатуры 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` — нет.
|