917 lines
44 KiB
Markdown
917 lines
44 KiB
Markdown
# Ранбук развёртывания Processing на VM2
|
||
|
||
Этот каталог — независимая основа VM2. Он не разворачивает VM1 и не
|
||
затрагивает `codebase/backend`. Все команды ниже — операторские; создание
|
||
репозитория их не выполняет.
|
||
|
||
## Блокеры production перед первым запуском
|
||
|
||
1. Замените каждый плейсхолдер в `.env` на проверенные несекретные значения.
|
||
Держите `BITRIX_SYNC_ENABLED=false`, пока не подписаны миграции, гранты,
|
||
поля портала, контракты роботов и cutover.
|
||
(для этого нужно еще образы отправить в conteiner registry, пункт 2)
|
||
2. Заполните каждую переменную `*_IMAGE` проверенным digest из registry.
|
||
Корневой Compose отклоняет отсутствующие ссылки на образы; изменяемые
|
||
теги не являются доказательством для production.
|
||
3. Устанавливайте production-файлы от `root:root`; пользователь `deploy` не
|
||
должен входить в группу `docker` и не должен иметь возможность писать
|
||
Compose, unit-файлы, хелперы, allow-list’ы или маппинги секретов.
|
||
(смысл: заходим под админом, sudo -i)
|
||
4. Заполните отдельные проверенные активные CIDR-файлы из двух `.template`.
|
||
Их закоммиченные активные версии намеренно содержат `deny all`.
|
||
(в services/nginx/allowlist прописываем разрешенные адреса - адрес ВМ1 и адрес битрикса)
|
||
5. Выпустите публичный ACME-сертификат в host-каталог `/etc/letsencrypt`.
|
||
Разместите CA управляемой PostgreSQL в `/etc/han/ca`. Выпустите
|
||
сертификат внутренней CA, SAN которого совпадает с приватным именем VM2.
|
||
Разрешайте хостовый порт `8443` только из SG VM1 и, при необходимости,
|
||
одобренных приватных/VPN-сетей операторов.
|
||
(выпуск сертификатов)
|
||
6. Создайте отдельный IAM-принципал Selectel для VM2. Он может читать только
|
||
имена из `deployment/secrets/config.example.json`. Никогда не
|
||
переиспользуйте принципал VM1.
|
||
(отдельный проект в селектел, туда отдельного сервисного пользователя с ролью member)
|
||
7. `REDIS_SAFETY_ACL` — полный ACL-файл, а не просто пароль. Он должен
|
||
открывать неаутентифицированный `PING` только для health и
|
||
защищённого паролем пользователя `safety`, ограниченного необходимыми
|
||
ключами/командами `han:safety:*`.
|
||
(Пароль в `MESSAGE_SAFETY_REDIS_URL` должен совпадать. Используйте `redis/redis-safety.acl.template`, заменив `REPLACE_WITH_LONG_RANDOM_PASSWORD)
|
||
8. Выделите отдельные учётные данные БД для runtime и миграций.
|
||
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` может мигрировать/активировать
|
||
политику, а `MESSAGE_SAFETY_DATABASE_URL` — нет; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
|
||
владеет DDL, а `BITRIX_SYNC_DATABASE_URL` — runtime-роль с минимальными
|
||
привилегиями. Учётные данные миграций монтируются только в jobs профиля
|
||
`ops`.
|
||
9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До
|
||
production ограничьте egress правилами Selectel SG/NAT/proxy до
|
||
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz и
|
||
источников ClamAV. Registry/package repositories оставляйте только на
|
||
controlled maintenance window.
|
||
|
||
## Кто что выполняет
|
||
|
||
- **Локальный компьютер оператора:** создаёт архив релиза и передаёт его на
|
||
VM2. Локальные команды ниже показаны для PowerShell.
|
||
- **`root` на VM2:** только bootstrap host OS, активация проверенного релиза,
|
||
установка root-owned файлов, настройка `.env`, secret mapping, credentials,
|
||
TLS/allow-list, миграции и первый старт.
|
||
- **`deploy` на VM2:** принимает релиз только в
|
||
`/var/lib/han-deploy/incoming`, проверяет статус/логи и запускает уже
|
||
установленные fixed systemd operations через точные sudo-правила.
|
||
`deploy` не запускает `docker`, не редактирует `/opt/han-chat/services` и не
|
||
входит в группу `docker`.
|
||
- **`admin` на VM2:** персональная break-glass роль с отдельным SSH-ключом и
|
||
отдельным локальным паролем для `sudo`. Не используется для штатного деплоя,
|
||
не входит в `docker`/`lxd`; каждый вход и sudo-вызов считается инцидентной
|
||
операцией.
|
||
|
||
## 1. Bootstrap свежей VM2
|
||
|
||
На локальном компьютере один раз создайте **два разных** ключа. Закрытые части
|
||
остаются только у соответствующих операторов и никогда не передаются на VM:
|
||
|
||
```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
|
||
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` скрипт отклоняет:
|
||
|
||
```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`
|
||
|
||
На локальном компьютере из каталога `HAN_chat_specification`:
|
||
|
||
```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.
|
||
|
||
Зашифруйте пароль 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. 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
|
||
```
|
||
|
||
До runtime выполните миграции отдельными DB roles и активируйте начальный
|
||
Message Safety config:
|
||
|
||
Перед первым `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>;
|
||
```
|
||
|
||
Первый запуск и enable выполняет `root` только после прохождения gates:
|
||
(внутри gate5)
|
||
|
||
```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
|
||
```
|
||
|
||
Установка/редактирование unit, Compose, `.env`, secret mapping, credential,
|
||
TLS, allow-list и запуск migration jobs остаются операциями `root`.
|
||
|
||
### 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 запрещён.
|
||
|
||
При обновлении 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
|
||
```
|
||
|
||
### 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`.
|
||
|
||
## Политика отказов
|
||
|
||
- Отказ зависимости 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` — нет.
|
||
|
||
|
||
# Gate 9 завершает техническую приёмку VM2, но не означает production cutover сервисов.
|
||
|
||
Дальнейший порядок:
|
||
|
||
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`: поля портала, webhooks, migrations, grants, backfill/watermark и rollback rehearsal.
|
||
|
||
Таким образом, ближайший шаг сейчас — reboot-gate и фиксация приёмки VM2. Затем переход к интеграции VM1, а не немедленное включение Bitrix. |