Реализация на отдельных двух машинах с протестированным взаимодействием по проверке сообщений

This commit is contained in:
mi
2026-08-19 18:24:00 +03:00
parent bbef7a30c9
commit c7a80e7256
103 changed files with 3457 additions and 3725 deletions
@@ -0,0 +1,477 @@
# Fresh production runbook ВМ1 HAN Chat
Это единственный исполняемый production-runbook ВМ1. Он предназначен только
для новой Ubuntu 24.04 VM: in-place преобразование старой single-VM/stub
инсталляции запрещено. Команды выполняет оператор; repository automation их не
запускает.
Запрещены: реальные секреты в репозитории, `.env`, argv/history или логах;
доступ `deploy` к Docker; mutable image tags; `docker compose down -v`;
Alembic downgrade; автоматический fallback Selectel → локальный файл.
## 0. Участники, переменные и stop conditions
- локальная машина: создаёт проверенный архив и SHA-256;
- `root`: bootstrap, активация release, config/secrets/TLS, миграции и первый
запуск;
- `deploy`: пишет только в `/var/lib/han-deploy/incoming`, затем использует
exact systemd/status/log commands;
- `admin`: персональная break-glass роль с отдельным ключом и sudo-паролем.
До окна работ зафиксируйте: `<GIT_SHA>`, image digests, `<EXPECTED_SHA256>`,
DNS/IP ВМ1, private DNS/SAN ВМ2, ops CIDR, PG/S3 inventory, PITR marker,
RPO/RTO, on-call, approver и предыдущий совместимый release.
Stop condition: любой placeholder, незакрытый preflight, несовпавший digest,
невалидный TLS, broad SG/sudo, неизвестная Alembic revision, stub/local Safety,
неуспешный negative probe или отсутствие rollback evidence.
## 1. Bootstrap новой ВМ1
На локальной машине создайте две разные Ed25519 key pairs. Private keys не
передаются на VM и не должны совпадать с bootstrap root key:
```powershell
ssh-keygen -t ed25519 -a 100 -f C:\Users\<USER>\.ssh\han_vm1_deploy `
-C "han-vm1-deploy"
ssh-keygen -t ed25519 -a 100 -f C:\Users\<USER>\.ssh\han_vm1_admin `
-C "han-vm1-break-glass-admin"
```
Передайте setup и только public keys во временный root-каталог. В уже открытой
bootstrap root-сессии:
```sh
install -d -m 0700 -o root -g root /root/bootstrap
install -m 0600 -o root -g root /tmp/han_vm1_deploy.pub /root/bootstrap/deploy.pub
install -m 0600 -o root -g root /tmp/han_vm1_admin.pub /root/bootstrap/admin.pub
install -m 0700 -o root -g root /tmp/setup-vm.sh /root/setup-vm1.sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
/root/setup-vm1.sh
passwd admin
```
Setup устанавливает host packages, Docker/Compose, UFW, fail2ban, security
updates, `DOCKER-USER`, swap и роли. Он не запускает Compose. Cloud SG должен
разрешать `80/443` из интернета. На bootstrap-этапе UFW временно разрешает SSH
из любой сети; после настройки WireGuard закройте public SSH и разрешите его
только через WireGuard. PG принимает TLS только от SG/private IP ВМ1. `6379`,
`4317/4318`, `8000`, `8080`, `9000` наружу запрещены.
Не закрывая root-сессию, проверьте в двух новых сессиях:
```powershell
ssh -i C:\Users\<USER>\.ssh\han_vm1_deploy deploy@<VM1_IP>
ssh -i C:\Users\<USER>\.ssh\han_vm1_admin admin@<VM1_IP>
```
В admin-сессии:
```sh
sudo -v
sudo -i
id
exit
```
Только после успеха обоих SSH-входов и admin sudo повторите в исходной
root-сессии:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
HARDEN_SSH=true SKIP_APT_UPGRADE=true \
/root/setup-vm1.sh
```
Откройте ещё по одной новой deploy/admin сессии после reload. Подтвердите
`PermitRootLogin no`, `AllowUsers deploy admin`, key-only auth и отсутствие
forwarding. Только затем закрывайте bootstrap root session. Root-only public
key files сохраните до установки helpers из первого release; private keys на
VM отсутствуют. Доступ cloud console/recovery остаётся break-glass.
## 2. Сборка и передача immutable release
На локальной машине checkout должен быть exact detached `<GIT_SHA>`, tree —
clean. Архив содержит один корень `backend`, не содержит `.env`, credentials,
caches и private keys:
```powershell
$Release = "<GIT_SHA>"
tar --exclude=backend/.env `
--exclude='backend/**/__pycache__' `
--exclude='backend/**/.pytest_cache' `
--exclude='backend/**/.ruff_cache' `
-czf "vm1-backend-$Release.tar.gz" `
-C .\VM1_app\codebase backend
Get-FileHash "vm1-backend-$Release.tar.gz" -Algorithm SHA256
scp "vm1-backend-$Release.tar.gz" `
deploy@<VM1_IP>:/var/lib/han-deploy/incoming/
```
Под `deploy` разрешены только inventory/checksum:
```sh
RELEASE='<GIT_SHA>'
cd /var/lib/han-deploy/incoming
sha256sum "vm1-backend-${RELEASE}.tar.gz"
tar -tzf "vm1-backend-${RELEASE}.tar.gz"
```
## 3. Root-активация: SHA и anti-traversal
Под `admin`, затем `sudo -i`. Не распаковывайте недоверенный архив до всех
проверок:
```sh
RELEASE='<GIT_SHA>'
EXPECTED_SHA256='<SHA256_FROM_APPROVED_WORKSTATION>'
ARCHIVE="/var/lib/han-deploy/incoming/vm1-backend-${RELEASE}.tar.gz"
printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check -
tar -tvzf "$ARCHIVE"
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^backend/\.env$)'; then
echo 'unsafe path or .env' >&2; exit 1
fi
if tar -tzf "$ARCHIVE" | grep -Ev '^backend(/|$)' | grep -q .; then
echo 'archive has files outside backend' >&2; exit 1
fi
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ {found=1} END {exit !found}'; then
echo 'symlink/hardlink is forbidden' >&2; exit 1
fi
TARGET="/opt/han-chat/releases/${RELEASE}"
test ! -e "$TARGET"
install -d -m 0755 -o root -g root "$TARGET"
tar --extract --gzip --file "$ARCHIVE" --directory "$TARGET" \
--no-same-owner --no-same-permissions
test -f "$TARGET/backend/docker-compose.yml"
chmod 0755 \
"$TARGET/backend/deployment/scripts/setup-vm.sh" \
"$TARGET/backend/deployment/preflight.sh" \
"$TARGET/backend/deployment/scripts/tls-deploy-hook.sh" \
"$TARGET/backend/deployment/secrets/han-compose" \
"$TARGET/backend/deployment/secrets/han-secrets"
test -x "$TARGET/backend/deployment/scripts/setup-vm.sh"
test -x "$TARGET/backend/deployment/preflight.sh"
test -x "$TARGET/backend/deployment/scripts/tls-deploy-hook.sh"
test -x "$TARGET/backend/deployment/secrets/han-compose"
test -x "$TARGET/backend/deployment/secrets/han-secrets"
if find "$TARGET/backend" -type l -print -quit | grep -q .; then exit 1; fi
chown -R root:root "$TARGET"
chmod -R go-w "$TARGET"
ln -s "releases/${RELEASE}" /opt/han-chat/.current-new
mv -Tf /opt/han-chat/.current-new /opt/han-chat/current
```
Executable modes поставляет release manifest/git. Нельзя применять blanket
`--chmod=F644` или рекурсивный `chmod`, снимающий executable bit. Сохраните
SHA-256 архива и release SHA в change record.
Повторите setup из активного release с теми же public key files. Он
установит root-owned launcher, secret unit, stack unit, TLS hook и sudoers, но
не запустит приложение:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
HARDEN_SSH=true SKIP_APT_UPGRADE=true \
/opt/han-chat/current/backend/deployment/scripts/setup-vm.sh
visudo -cf /etc/sudoers.d/deploy
sudo -l -U deploy
rm -f /root/bootstrap/deploy.pub /root/bootstrap/admin.pub
```
Убедитесь, что в выводе нет wildcard, shell/editor/cp/chmod/docker и что
`deploy` не входит в `docker`, `sudo`, `lxd`, `adm`, `systemd-journal`.
Для повторного setup после будущего release заново передайте только проверенные
public keys в root-only временный каталог и удалите их после выполнения.
## 4. Несекретный config и secrets
Под root создайте `/etc/han/vm1.env` из reviewed production template. Config
живёт вне immutable release и не меняется при rollback. В нём только
несекретные значения и paths; `APP_ENV=production`,
`SECRETS_SOURCE=selectel`, все images pinned `@sha256:...`,
`MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS>:8443`,
`MESSAGE_SAFETY_EXTRA_HOST=<VM2_PRIVATE_DNS>=<VM2_PRIVATE_IP>`,
`MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem`,
`NGINX_TLS_CERTIFICATE=/run/tls/fullchain.pem`,
`NGINX_TLS_CERTIFICATE_KEY=/run/tls/privkey.pem`.
```sh
cd /opt/han-chat/current/backend
install -m 0600 -o root -g root .env.example /etc/han/vm1.env
editor /etc/han/vm1.env
./scripts/validate-env /etc/han/vm1.env
install -m 0600 -o root -g root deployment/secrets/config.example.json \
/etc/han/secrets/production.selectel.json
editor /etc/han/secrets/production.selectel.json
```
Mapping должен содержать только VM1 secrets. Убедитесь, что в нём отсутствуют
legacy local `message-safety`/`bitrix-sync` consumers, Redis DB2 и credentials
сервисов ВМ2; отдельный IAM principal ВМ1 получает read-only только к
перечисленным remote names. Пароли/DSN/token/S3 keys не помещаются в
`/etc/han/vm1.env`.
Создайте encrypted systemd credential без значения в argv/history:
```sh
read -rsp 'Selectel VM1 service-user password: ' SELECTEL_PASSWORD; echo
printf '%s' "$SELECTEL_PASSWORD" | systemd-creds encrypt \
--name=selectel-service-user-password - \
/etc/han/credentials/production.selectel-password.cred
unset SELECTEL_PASSWORD
chown root:root /etc/han/credentials/production.selectel-password.cred
chmod 0600 /etc/han/credentials/production.selectel-password.cred
```
Подтвердите token pairs без печати значений средствами validator. Не делайте
`cat` runtime secret files. Emergency file mode — отдельная root-only
процедура без automatic fallback.
## 5. Managed PostgreSQL и S3
Установите provider CA вне release:
```sh
install -m 0644 -o root -g root /tmp/<PG_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/<PG_CA_FILE>
```
Runtime и migration roles разделены для `han_app`, `bitrix_local`, `sms`,
`keycloak`; runtime не имеет DDL/ownership. DSN проверяет hostname и chain.
Включены encryption, deletion protection, backup/PITR и alerts. Перед
миграциями создаётся provider PITR marker.
S3: private encrypted buckets documents/attachments/quarantine; exact browser
origin CORS; prefix-scoped VM1 key; versioning/lifecycle; public ACL off.
Read-only quarantine key Safety принадлежит IAM ВМ2 и не копируется на ВМ1.
Проверьте negative access к чужому prefix/bucket.
## 6. Public TLS и internal VM2 CA
Установите внутренний CA, которым ВМ1 проверяет SAN ВМ2:
```sh
install -m 0644 -o root -g root /tmp/<VM2_INTERNAL_CA_FILE> \
/etc/han/ca/vm2-internal-ca.pem
openssl x509 -in /etc/han/ca/vm2-internal-ca.pem \
-noout -subject -issuer -dates
rm -f /tmp/<VM2_INTERNAL_CA_FILE>
openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
-servername <VM2_PRIVATE_DNS> -verify_hostname <VM2_PRIVATE_DNS> \
-CAfile /etc/han/ca/vm2-internal-ca.pem -verify_return_error </dev/null
```
Для initial public certificate DNS уже указывает на ВМ1, `80` свободен:
```sh
PUBLIC_HOST='<PUBLIC_HOST>'
ACME_EMAIL='<OPS_EMAIL>'
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
certbot certonly --standalone --preferred-challenges http \
-d "$PUBLIC_HOST" --cert-name "$PUBLIC_HOST" \
--email "$ACME_EMAIL" --agree-tos --no-eff-email --non-interactive
```
При первом копировании nginx ещё не работает, поэтому deploy hook до запуска
не вызывается. Скопируйте initial pair теми же root ownership и mode, затем
preflight проверит pair:
```sh
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
```
В production Compose nginx обязан монтировать только
`/var/lib/han-chat/public-tls` read-only, не `/etc/letsencrypt`. Если это ещё
legacy named volume, preflight/Compose review — блокер, не workaround.
## 7. Secret sync и static preflight
```sh
systemctl restart han-secrets@production.service
systemctl is-active han-secrets@production.service
journalctl --no-pager -u han-secrets@production.service
test -s /run/han-chat/secrets/manifest
cut -d= -f1 /run/han-chat/secrets/manifest | sort
/opt/han-chat/current/backend/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
```
В списке production services после VM2 cutover нет local `message-safety`,
`bitrix-sync`, Redis DB2 и test stubs. Только nginx публикует `80/443`; все
images immutable. Не сохраняйте resolved Compose с secret paths/metadata в
общедоступный файл.
## 8. Миграции и seed
После PITR marker под root:
```sh
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-api
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-bitrix-local
/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-sms
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
```
Проверьте current/head каждой схемы, идемпотентность seed и negative DDL от
runtime roles. Миграции ВМ1 `han_app` precede dependent VM2 sync cutover.
Временные cross-schema grants выдаёт owner и отзывает после проверки. Unknown
revision, multiple heads или incompatible schema — stop; ручной `stamp` и
downgrade запрещены.
## 9. Упорядоченный первый запуск
Первый запуск выполняет root через фиксированный launcher:
```sh
/usr/local/sbin/han-vm1-compose up -d redis otel-queue-init otel-collector
/usr/local/sbin/han-vm1-compose up -d keycloak sms-service
/usr/local/sbin/han-vm1-compose up -d api-backend bitrix-local-app
/usr/local/sbin/han-vm1-compose up -d \
sms-worker delivery-worker safety-recovery-worker cleanup-worker \
notification-expire-worker notification-draft-cleanup-worker
/usr/local/sbin/han-vm1-compose up -d frontend-static
/usr/local/sbin/han-vm1-compose up -d nginx
/usr/local/sbin/han-vm1-compose ps
```
Дождитесь health, затем:
```sh
/usr/local/sbin/han-vm1-compose exec -T nginx nginx -t -c /tmp/nginx.conf
NGINX_ID=$(/usr/local/sbin/han-vm1-compose ps --status running --quiet nginx)
test -n "$NGINX_ID"
docker kill --signal HUP "$NGINX_ID" >/dev/null
unset NGINX_ID
systemctl enable han-secrets@production.service han-stack@production.service
systemctl start han-stack@production.service
```
`han-stack@production` становится единственным routine lifecycle interface.
После изменения secret source сначала explicit restart secret unit, затем
stack unit. Обновление active/exited oneshot всегда требует `restart`.
## 10. Smoke, firewall и cutover
С внешней машины:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' http://<PUBLIC_HOST>/
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
curl -fsS https://<PUBLIC_HOST>/auth/realms/han-chat/.well-known/openid-configuration
curl -sS -o /dev/null -w '%{http_code}\n' \
https://<PUBLIC_HOST>/internal/safety/v2/messages/check
openssl s_client -connect <PUBLIC_HOST>:443 -servername <PUBLIC_HOST> \
-verify_hostname <PUBLIC_HOST> -verify_return_error </dev/null
```
Ожидается `308`, public endpoints `200`, internal route `404`, valid chain.
Проверьте guest/auth PKCE/OTP, SMS mode, Open Lines, idempotency, ownership,
rate limits, WS reconciliation, S3 quarantine/promote/deny и Safety v2
allow/deny/pending/timeout. Safety status `stub` не принимается.
До переключения `MESSAGE_SAFETY_URL` ВМ2 должна закрыть собственные gates.
После переключения подтвердите private CA/SAN, service token, `text|links|files`
capabilities и fail-closed timeout. Local Safety/Redis DB2 не оставляются как
fallback. Cutover ВМ2 Bitrix sync — отдельное окно.
Проверьте `DOCKER-USER` live counters внешним positive `80/443` и negative
port/source probe:
```sh
iptables -L HAN-CHAT-DOCKER -n -v
systemctl restart han-chat-docker-firewall.service
systemctl restart docker.service
systemctl restart han-chat-docker-firewall.service
iptables -S HAN-CHAT-DOCKER
```
Allow rules должны использовать `--ctorigdstport 80/443`; UFW INPUT counter не
является доказательством published Docker ports.
## 11. Certbot, observability и reboot gate
После запуска nginx переключите renewal на webroot, который Compose монтирует
из `/var/lib/han-chat/acme`, затем:
```sh
certbot reconfigure --cert-name '<PUBLIC_HOST>' \
--authenticator webroot --webroot-path /var/lib/han-chat/acme
certbot renew --dry-run --run-deploy-hooks
systemctl enable --now certbot.timer
systemctl list-timers certbot.timer
```
Hook должен завершаться `0` с пустым stderr на success, атомарно обновлять
staging, выполнять config test и HUP.
Проверьте logs/metrics/traces, request ID через nginx/API/VM2, alerts для
5xx/auth/Safety/PG/Redis/OOM/disk/OTEL queue/TLS. Отправьте только fake canary
token/PII markers и докажите их отсутствие в logs/traces.
Перед reboot проверьте admin SSH и provider console:
```sh
systemctl is-enabled docker.service han-chat-docker-firewall.service \
han-secrets@production.service han-stack@production.service certbot.timer
systemctl reboot
```
После reconnect повторите status, stack `ps`, public/private TLS, smoke,
negative ports и firewall counters. Без reboot gate deployment не завершён.
## 12. Rollback и disaster recovery
Application rollback допускается только на предыдущий root-owned release,
совместимый с текущей schema. Секреты и `/etc/han/vm1.env` остаются текущими:
```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
```
Повторите smoke и зафиксируйте digests. Не удаляйте current/previous release,
active images, evidence или volumes. После incompatible migration используйте
forward fix либо согласованный PG PITR + S3/Bitrix reconciliation в maintenance
window; Redis восстанавливается пустым и прогревается из PG.
Потеря ВМ1: создайте новую VM этим runbook, восстановите DNS/SG, root-owned
approved release, VM1 IAM secrets, managed PG/S3 и public TLS; ВМ2 не
пересоздавайте. До cutover держите traffic закрытым. Ежеквартально делайте
isolated restore rehearsal, не направляя production DNS/Bitrix callbacks, и
фиксируйте фактические RPO/RTO.
При компрометации host не «очищайте» VM: изолируйте, сохраните evidence,
ротируйте доступные secrets/tokens и reprovision из trusted image.
## 13. Production acceptance record
Сохраните без secret values: release/archive SHA-256, image digests, schema
heads, realm/config version, PG PITR marker, TLS fingerprints/expiry, systemd
status, positive/negative firewall probes, public/private smokes, VM2 cutover
approval, observability canary, reboot result, rollback/restore rehearsal и
подписи Operations/Security/Product.