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

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
@@ -1,14 +1,22 @@
# VM2 Processing deployment runbook
This directory is the independent VM2 foundation. It does not deploy VM1 or
`codebase/backend`. All commands below are operator commands; repository
creation does not execute them.
This directory is the independent VM2 foundation in the `VM2_services`
repository. It does not deploy VM1 or `VM1_app/codebase/backend`. All commands
below are operator commands; repository creation does not execute them.
Repository root: `HAN_chat_specification/VM2_services`. Compose and deployment
artifacts live under `VM2_services/codebase/services/`. Local operator commands
assume the current directory is `VM2_services` unless stated otherwise.
The full step-by-step procedure with gates and copy-paste commands is in
[`RUNBOOK.ru.md`](RUNBOOK.ru.md).
## Production blockers before first start
1. Replace every `.env` placeholder with reviewed non-secret values. Keep
`BITRIX_SYNC_ENABLED=false` until migrations, grants, portal fields, robot
contracts and cutover are signed off.
contracts and cutover are signed off. Build and push application images to
the registry first (blocker 2).
2. Fill every `*_IMAGE` variable with a reviewed registry digest. Root Compose
rejects missing image references; mutable tags are not production evidence.
3. Install production files as `root:root`; `deploy` must not be in `docker`
@@ -38,61 +46,156 @@ creation does not execute them.
S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz and ClamAV destinations.
Registry/package access exists only during controlled maintenance windows.
## Install
## Who runs what
- Bootstrap a fresh Ubuntu 24.04 VM as root with
`deployment/scripts/setup-vm.sh`, supplying `VM1_PRIVATE_CIDRS`, optional
private/VPN `OPS_CIDRS`, and separate Ed25519 public-key files for deploy and
break-glass admin. SSH is publicly reachable but key-only and protected by
fail2ban; the CIDR variables apply only to private port `8443`. The script
installs host packages/firewalls and roles but never starts Compose. Set a
separate admin sudo password; verify deploy login, admin login and admin sudo
in independent sessions before rerunning with `HARDEN_SSH=true`.
Root/deploy/admin key reuse is rejected.
- Checkout an immutable release under `/opt/han-chat/services`.
- Copy `.env.example` to root-owned mode `0600` `.env`.
- Install `secrets_loader.py` and `han-secrets` under
`/usr/local/lib/han-secrets-vm2/`, root-owned and non-writable.
- Install `han-compose` as `/usr/local/sbin/han-vm2-compose`.
- Install `han-secrets-vm2.service` and `han-processing.service` under
`/etc/systemd/system/`.
- Install `han-message-safety-mode` as root-owned `0755` and the sudoers
template as `/etc/sudoers.d/deploy-message-safety-mode` mode `0440`; validate
with `visudo -cf`. Create the dedicated host group `han-message-safety` with
GID `10001`. Before the first Compose validation, create
`/etc/han-chat/message-safety-mode.env` as
`root:han-message-safety 0640` with all three flags `false` (or invoke the
helper's `standard` transition after the fixed launcher is installed).
- Install loader config using the exact `APP_ENV` suffix. With the committed
example (`APP_ENV=production-like`) the path is
`/etc/han/secrets/vm2-production-like.selectel.json` mode `0600`. For
controlled no-provider recovery use an explicit `file`
config pointing to a root-only `0700` directory containing exactly one file
per configured key. Selectel failure never falls back automatically.
- **Operator workstation:** builds the release archive and transfers it to VM2.
Local examples use PowerShell from `VM2_services`.
- **`root` on VM2:** host bootstrap, verified release activation, root-owned
files, `.env`, secret mapping, credentials, TLS/allow-lists, migrations and
first start.
- **`deploy` on VM2:** accepts releases only in `/var/lib/han-deploy/incoming`,
checks status/logs and runs installed fixed systemd operations through exact
sudo rules. `deploy` must not run `docker`, edit `/opt/han-chat/services` or
join the `docker` group.
- **`admin` on VM2:** personal break-glass role with a separate SSH key and
local sudo password. Not used for routine deploy; not in `docker`/`lxd`; every
login and sudo call is an incident operation.
## Preflight and startup
## Prerequisites (before §1)
Run `deployment/preflight.sh` first. Then, through the approved root units:
This runbook covers operations **on an already provisioned VM2**. Prepare outside
Compose first:
1. synchronize secrets; any missing/oversized/invalid secret blocks startup;
2. validate resolved Compose without storing its output;
3. run the two `ops` migration jobs and create/activate the reviewed initial
Message Safety config before starting either runtime;
4. validate nginx config and both certificate chains;
5. start Redis/Collector, ClamAV, application API/workers, then nginx;
6. verify that only nginx publishes `80`, `443`, and private-bound `8443`;
7. verify all non-exact public paths return `404`, HTTP webhook paths return
`426` without redirect/query reflection, wrong methods fail, and wrong
source CIDRs are rejected before upstream;
8. verify private Safety check/task/status and sync status only from approved
callers; verify public `/internal/*` is `404`;
9. canary telemetry with a fake token marker and prove query, form body,
Authorization, DSN, S3 key and object key are absent from logs/traces.
1. **Selectel infrastructure** — VPC/subnet, SG (public `80/443/22`; private
`8443` only; default-deny egress after bootstrap), sizing (4 vCPU / 8 GB RAM /
80 GB SSD — see
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md)),
public and private VM2 IPs, DNS A record for `PROCESSING_PUBLIC_HOST`.
2. **Managed PostgreSQL** — schemas/roles for `message_safety` and
`bitrix_sync`, separate migration/runtime DSNs; see
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis, clamav,
otel-collector).
4. **Selectel Secrets Manager** — populate all remote names from
`deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys,
`REDIS_SAFETY_ACL`, internal TLS PEM for `8443`). Dedicated VM2 IAM principal
with read-only access to those names only.
5. **S3 quarantine bucket** and SigNoz OTLP endpoint — non-secret values in
`.env`.
6. **Internal TLS** — internal-CA certificate with SAN = VM2 private DNS; PEM
stored in Secrets Manager, not in the release tree.
Section order: §1–§5 → Gates 19 → §7 (post-acceptance). Run
`systemctl enable` and `systemctl start han-processing.service` **only after
Gate 5 succeeds**.
## 1. Bootstrap a fresh VM2
From `VM2_services` on the operator workstation, copy the setup script:
```powershell
scp -i C:\Users\MI\.ssh\hansel `
.\codebase\services\deployment\scripts\setup-vm.sh `
root@<VM2_PUBLIC_IP>:/root/setup-vm2.sh
```
Bootstrap as root with separate Ed25519 deploy/admin keys, `VM1_PRIVATE_CIDRS`,
optional `OPS_CIDRS`, admin sudo password, deploy/admin login verification, then
rerun with `HARDEN_SSH=true`. Full commands: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §1.
## 2. Release transfer under `deploy`
From `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` verifies SHA-256 and archive listing only; it must not unpack into
production. Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §2–§3.
## 3. Activation and root-owned install
`root` verifies the archive, extracts to staging, rsyncs into
`/opt/han-chat/services`, reruns `setup-vm.sh` to install helpers/units. Details:
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §3.
## 4. Non-secret config and Selectel
Copy `.env.example``.env`, install loader config as
`/etc/han/secrets/vm2-<APP_ENV>.selectel.json`, encrypt Selectel service-user
password with `systemd-creds`, edit nginx allow-lists. Details:
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §4.
## 5. PostgreSQL CA and initial public TLS
Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for
`PROCESSING_PUBLIC_HOST`, stage public cert/key for nginx. Details:
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §5.
## 6. Gates 19: preflight, migrations and first start
Execute gates **in order** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Do not enable
`han-processing.service` until Gate 5 completes successfully.
| Gate | Purpose |
| --- | --- |
| 1 | Secrets materialized via `han-secrets-vm2.service` |
| 2 | Static preflight and resolved Compose (`@sha256:` images) |
| 3 | Migrations, initial Message Safety config create/activate |
| 4 | nginx `-t` with TLS and upstream placeholders |
| 5 | Ordered `compose up`; then `systemctl enable/start han-processing` |
| 6 | Host ports, public/private certificate chains, certbot renewal |
| 7 | Public routing smoke (308/404/426/405) |
| 8 | Private Safety/sync API from VM1/ops only |
| 9 | Fake-token canary — no secrets in logs/traces |
After Gate 5, `deploy` may run:
```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
```
Full gate commands: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §6.
Do not open webhook traffic while `bitrix-sync` is disabled. A disabled or
failed receiver must return retryable `503`/closed routing, never successful
`2xx ignored`.
## 7. After Gate 9 — post-acceptance
Gate 9 completes VM2 technical acceptance but **does not** authorize Message
Safety cutover on VM1 or Bitrix sync enablement.
1. Verify autostart: `docker`, `han-chat-vm2-docker-firewall`, `han-secrets-vm2`,
`han-processing`, `certbot.timer`.
2. Reboot-gate: `systemctl reboot`, then repeat autostart checks and Gates 68
briefly.
3. Record release evidence: `han-vm2-compose config --images`, `ps`, certbot
timer, unit journals — without secret values.
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, ClamAV
signature age, OTEL queue, disk/RAM, MOCK mode, private Safety API).
5. Proceed to controlled Message Safety cutover on VM1 — see
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md) §13.
6. Keep `BITRIX_SYNC_ENABLED=false` and `BITRIX_SYNC_MODE=disabled`; Bitrix
public allow-list remains `deny all;` until
[`module-07-bitrix-sync.md`](../../../documentation/module-07-bitrix-sync.md)
cutover gates are signed off.
Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §7.
## Failure policy
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
@@ -1,8 +1,12 @@
# Ранбук развёртывания Processing на VM2
Этот каталог — независимая основа VM2. Он не разворачивает VM1 и не
затрагивает `codebase/backend`. Все команды ниже — операторские; создание
репозитория их не выполняет.
Этот каталог — независимая основа VM2 в репозитории `VM2_services`. Он не
разворачивает VM1 и не затрагивает `VM1_app/codebase/backend`. Все команды
ниже — операторские; создание репозитория их не выполняет.
Корень репозитория ВМ2: `HAN_chat_specification/VM2_services`. Compose и
deployment-артефакты: `VM2_services/codebase/services/`. Локальные команды
ниже предполагают текущий каталог `VM2_services`, если не указано иное.
## Блокеры production перед первым запуском
@@ -64,6 +68,33 @@
не входит в `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).
4. **Selectel Secrets Manager** — заполнить все remote names из
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
`REDIS_SAFETY_ACL`, internal TLS PEM для `8443`). Отдельный IAM principal
VM2 с read-only доступом только к этим именам.
5. **S3 quarantine bucket** и SigNoz OTLP endpoint — значения в `.env`.
6. **Internal TLS** — сертификат внутренней CA с SAN = private DNS VM2;
PEM хранится в Secrets Manager, не в каталоге релиза.
Порядок разделов §1–§5 → Gates 19 → §7 (post-acceptance). `systemctl enable`
и `systemctl start han-processing.service` — **только после успешного Gate 5**.
## 1. Bootstrap свежей VM2
На локальном компьютере один раз создайте **два разных** ключа. Закрытые части
@@ -85,8 +116,9 @@ break-glass оператору и храниться отдельно от deplo
каталог:
```powershell
# текущий каталог: ...\HAN_chat_specification\VM2_services
scp -i C:\Users\MI\.ssh\hansel `
.\HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh `
.\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 `
@@ -161,8 +193,8 @@ sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub
```
## 2. Передача релиза под `deploy`
На локальном компьютере из каталога `HAN_chat_specification`:
На локальном компьютере из каталога `VM2_services`:
```powershell
$Release = "<VERSION_OR_GIT_SHA>"
@@ -390,71 +422,16 @@ Nginx с primary GID `11001` получает только подготовле
`/etc/letsencrypt` остаётся доступен только root/Certbot. Не копируйте private
key в каталог релиза и не делайте его world-readable.
## 6. Preflight, миграции и первый запуск под `root`
## 6. Gates 19: 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
```
Выполняйте 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 — секреты материализованы
@@ -490,7 +467,39 @@ deployment/preflight.sh
### Gate 3 — миграции и активный Message Safety config
Команды миграций из предыдущего раздела выполняются под `root`. После них:
Под `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 \
@@ -623,6 +632,16 @@ Message Safety; проверьте её без вывода секретов:
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 и сертификаты
@@ -811,47 +830,10 @@ unset CANARY
упавший receiver должен возвращать retryable `503`/закрытую маршрутизацию,
никогда успешный `2xx ignored`.
## Политика отказов
## 7. После Gate 9 — post-acceptance
- Отказ зависимости 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 сервисов.
Gate 9 завершает техническую приёмку VM2, но **не** означает production
cutover Message Safety на VM1 и **не** разрешает включать Bitrix sync.
Дальнейший порядок:
@@ -912,6 +894,237 @@ BITRIX_SYNC_ENABLED=false
BITRIX_SYNC_MODE=disabled
```
Public allow-list — только `deny all;`. Включать Bitrix можно лишь после выполнения gates `module-07`: поля портала, webhooks, migrations, grants, backfill/watermark и rollback rehearsal.
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.
Таким образом, ближайший шаг сейчас — reboot-gate и фиксация приёмки VM2. Затем переход к интеграции VM1, а не немедленное включение Bitrix.
После успешного 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` — нет.
@@ -0,0 +1,588 @@
#!/usr/bin/env bash
# Создание OS-пользователя tunnel на VM2 для SSH local port forwarding к PostgreSQL/PgBouncer.
#
# Контракт: arch-06 — только local forwarding, PermitOpen allow-list, без sudo/shell.
# На VM2 по умолчанию AllowTcpForwarding=no; исключение только для tunnel.
#
# Использование (на VM2 под root):
# TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \
# PG_HOST=192.168.0.210 \
# PG_PORT=6433 \
# bash setup-tunnel-user.sh
#
# Опционально:
# TUNNEL_USER=tunnel
# TUNNEL_SOURCE_CIDRS="203.0.113.10/32,198.51.100.0/24" # ограничить источники SSH
# TUNNEL_ALLOW_ANY_SOURCE=true # осознанно разрешить ключу вход с любого адреса
# EXTRA_PERMIT_OPEN="192.168.0.210:5433" # доп. endpoint (direct PG)
# SSHD_MAIN_CONF=/etc/ssh/sshd_config.d/00-han-chat-vm2.conf
#
# По умолчанию TUNNEL_SOURCE_CIDRS обязателен. Разрешение любого источника
# требует явного TUNNEL_ALLOW_ANY_SOURCE=true и должно компенсироваться SG/firewall.
set -Eeuo pipefail
IFS=$'\n\t'
TUNNEL_USER="${TUNNEL_USER:-tunnel}"
TUNNEL_AUTHORIZED_KEY_FILE="${TUNNEL_AUTHORIZED_KEY_FILE:-}"
TUNNEL_SOURCE_CIDRS="${TUNNEL_SOURCE_CIDRS:-}"
TUNNEL_ALLOW_ANY_SOURCE="${TUNNEL_ALLOW_ANY_SOURCE:-false}"
PG_HOST="${PG_HOST:-192.168.0.210}"
PG_PORT="${PG_PORT:-6433}"
EXTRA_PERMIT_OPEN="${EXTRA_PERMIT_OPEN:-}"
SSHD_MAIN_CONF="${SSHD_MAIN_CONF:-/etc/ssh/sshd_config.d/00-han-chat-vm2.conf}"
SSHD_TUNNEL_CONF="/etc/ssh/sshd_config.d/50-han-tunnel-user.conf"
AUTHORIZED_KEYS_DIR="/etc/ssh/authorized_keys"
AUTHORIZED_KEYS_FILE="${AUTHORIZED_KEYS_DIR}/${TUNNEL_USER}"
declare -a PERMIT_OPENS=()
log() { printf '[setup-tunnel] %s\n' "$*"; }
step() { printf '\n=== %s ===\n' "$*"; }
die() { printf '[setup-tunnel] ERROR: %s\n' "$*" >&2; exit 1; }
require_root() {
[[ "$(id -u)" -eq 0 ]] || die "Скрипт нужно запускать от root"
}
require_commands() {
local command
for command in awk chmod cp getent grep id install mktemp mv passwd python3 rm \
ssh-keygen sshd stat systemctl useradd usermod; do
command -v "$command" >/dev/null 2>&1 || die "Не найдена обязательная команда: ${command}"
done
}
validate_tunnel_user() {
[[ "$TUNNEL_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "Некорректное имя пользователя: ${TUNNEL_USER}"
}
validate_source_policy() {
[[ "$TUNNEL_ALLOW_ANY_SOURCE" == "true" || "$TUNNEL_ALLOW_ANY_SOURCE" == "false" ]] \
|| die "TUNNEL_ALLOW_ANY_SOURCE должен быть true или false"
if [[ -n "$TUNNEL_SOURCE_CIDRS" && "$TUNNEL_ALLOW_ANY_SOURCE" == "true" ]]; then
die "Задайте либо TUNNEL_SOURCE_CIDRS, либо TUNNEL_ALLOW_ANY_SOURCE=true, но не оба"
fi
if [[ -z "$TUNNEL_SOURCE_CIDRS" && "$TUNNEL_ALLOW_ANY_SOURCE" != "true" ]]; then
die "TUNNEL_SOURCE_CIDRS обязателен; для осознанного отказа задайте TUNNEL_ALLOW_ANY_SOURCE=true"
fi
if [[ "$TUNNEL_ALLOW_ANY_SOURCE" == "true" ]]; then
log "ВНИМАНИЕ: источник SSH-ключа не ограничен; доступ должен ограничиваться SG/firewall"
fi
}
validate_cidrs() {
local cidrs=$1
local err normalized
[[ -n "$cidrs" ]] || return 0
[[ "$cidrs" =~ ^[0-9A-Fa-f:./,[:space:]]+$ ]] \
|| die "TUNNEL_SOURCE_CIDRS содержит недопустимые символы"
if ! err=$(python3 - "$cidrs" 2>&1 <<'PY'
import ipaddress
import sys
parts = [raw.strip() for raw in sys.argv[1].split(",")]
if not parts or any(not part for part in parts):
print("TUNNEL_SOURCE_CIDRS содержит пустой элемент", file=sys.stderr)
raise SystemExit(1)
for cidr in parts:
try:
ipaddress.ip_network(cidr, strict=False)
except ValueError as exc:
print(f"Некорректный CIDR: {cidr} ({exc})", file=sys.stderr)
raise SystemExit(1) from exc
PY
); then
die "${err:-TUNNEL_SOURCE_CIDRS содержит некорректный CIDR}"
fi
normalized=$(python3 - "$cidrs" <<'PY'
import ipaddress
import sys
print(",".join(
str(ipaddress.ip_network(part.strip(), strict=False))
for part in sys.argv[1].split(",")
))
PY
)
TUNNEL_SOURCE_CIDRS=$normalized
}
validate_host_port() {
local host=$1 port=$2 label=$3
[[ -n "$host" ]] || die "${label}: host пустой"
[[ "$host" =~ ^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$ ]] \
|| die "${label}: host содержит недопустимые символы"
[[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )) \
|| die "${label}: некорректный port ${port}"
}
parse_host_port_item() {
local item=$1 label=$2
local host port
item="${item//[[:space:]]/}"
[[ -n "$item" ]] || die "${label}: пустой host:port"
[[ "$item" == *:* ]] || die "${label}: ожидается host:port, получено ${item}"
host="${item%%:*}"
port="${item##*:}"
[[ "$host" != "$port" ]] || die "${label}: отсутствует port в ${item}"
validate_host_port "$host" "$port" "$label"
printf '%s:%s\n' "$host" "$port"
}
collect_permit_opens() {
local item host_port existing duplicate
local -a extra=()
validate_host_port "$PG_HOST" "$PG_PORT" "PG"
PERMIT_OPENS=("${PG_HOST}:${PG_PORT}")
if [[ -n "$EXTRA_PERMIT_OPEN" ]]; then
IFS=',' read -r -a extra <<< "$EXTRA_PERMIT_OPEN"
((${#extra[@]} > 0)) || die "EXTRA_PERMIT_OPEN не содержит endpoint"
for item in "${extra[@]}"; do
host_port=$(parse_host_port_item "$item" "EXTRA_PERMIT_OPEN")
duplicate=false
for existing in "${PERMIT_OPENS[@]}"; do
[[ "$existing" == "$host_port" ]] && duplicate=true
done
if [[ "$duplicate" == "false" ]]; then
PERMIT_OPENS+=("$host_port")
fi
done
fi
}
validate_key_file() {
local file=$1
local lines
[[ -n "$file" && -f "$file" && -s "$file" ]] \
|| die "Не найден TUNNEL_AUTHORIZED_KEY_FILE: ${file:-<empty>}"
ssh-keygen -l -f "$file" >/dev/null 2>&1 || die "Некорректный SSH public key: ${file}"
mapfile -t lines < <(grep -Ev '^[[:space:]]*(#|$)' "$file")
((${#lines[@]} == 1)) || die "Файл ключа должен содержать ровно одну строку без комментариев: ${file}"
[[ "${lines[0]}" =~ ^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$ ]] \
|| die "Разрешён только чистый ssh-ed25519 без SSH-опций: ${file}"
}
read_ed25519_public_key() {
local file=$1
local line key_type key_data ignored_comment
line=$(grep -Ev '^[[:space:]]*(#|$)' "$file")
IFS=$' \t' read -r key_type key_data ignored_comment <<< "$line"
[[ "$key_type" == "ssh-ed25519" ]] || die "Разрешён только ssh-ed25519"
[[ "$key_data" =~ ^[A-Za-z0-9+/=]+$ ]] || die "Некорректное тело публичного ключа"
ED25519_KEY_TYPE=$key_type
ED25519_KEY_DATA=$key_data
# Входной комментарий не влияет на авторизацию и намеренно не переносится.
ED25519_KEY_COMMENT=han-vm2-db-tunnel
}
build_authorized_keys_line() {
local key_file=$1
local from_prefix="" options host_port
read_ed25519_public_key "$key_file"
options='restrict,port-forwarding'
if [[ -n "$TUNNEL_SOURCE_CIDRS" ]]; then
from_prefix="from=\"${TUNNEL_SOURCE_CIDRS}\","
fi
for host_port in "${PERMIT_OPENS[@]}"; do
options+=",permitopen=\"${host_port}\""
done
if [[ -n "$from_prefix" ]]; then
printf '%s%s %s %s %s\n' \
"$from_prefix" "$options" "$ED25519_KEY_TYPE" "$ED25519_KEY_DATA" "$ED25519_KEY_COMMENT"
else
printf '%s %s %s %s\n' \
"$options" "$ED25519_KEY_TYPE" "$ED25519_KEY_DATA" "$ED25519_KEY_COMMENT"
fi
}
create_or_update_user() {
step "Создание пользователя ${TUNNEL_USER}"
local actual_home actual_shell primary_group home_owner
if id "$TUNNEL_USER" >/dev/null 2>&1; then
IFS=: read -r _ _ _ _ _ actual_home actual_shell < <(getent passwd "$TUNNEL_USER")
primary_group=$(id -gn "$TUNNEL_USER")
[[ "$actual_home" == "/home/${TUNNEL_USER}" ]] \
|| die "Существующий ${TUNNEL_USER} имеет неожиданный home: ${actual_home}"
[[ "$primary_group" == "$TUNNEL_USER" ]] \
|| die "Существующий ${TUNNEL_USER} имеет неожиданную primary group: ${primary_group}"
[[ "$actual_shell" == "/usr/sbin/nologin" ]] \
|| die "Отказываюсь переоборудовать существующего ${TUNNEL_USER} с shell ${actual_shell}"
[[ -d "$actual_home" && ! -L "$actual_home" ]] \
|| die "Home ${actual_home} отсутствует, не является каталогом или является симлинком"
home_owner=$(stat -c '%U' "$actual_home")
[[ "$home_owner" == "$TUNNEL_USER" ]] \
|| die "Home ${actual_home} принадлежит ${home_owner}, ожидался ${TUNNEL_USER}"
log "Найден ранее созданный выделенный пользователь ${TUNNEL_USER}"
else
getent group "$TUNNEL_USER" >/dev/null 2>&1 \
&& die "Группа ${TUNNEL_USER} уже существует без одноимённого пользователя"
useradd --create-home --user-group --home-dir "/home/${TUNNEL_USER}" \
--shell /usr/sbin/nologin "$TUNNEL_USER"
fi
passwd -l "$TUNNEL_USER" >/dev/null
usermod --lock --groups "" "$TUNNEL_USER"
[[ "$(id -nG "$TUNNEL_USER")" == "$TUNNEL_USER" ]] \
|| die "Не удалось удалить дополнительные группы пользователя ${TUNNEL_USER}"
}
build_authorized_keys_candidate() {
local target=$1
build_authorized_keys_line "$TUNNEL_AUTHORIZED_KEY_FILE" > "$target"
# sshd читает AuthorizedKeysFile с правами целевого пользователя.
# Файл остаётся root-owned и поэтому 0644 не позволяет tunnel изменить ключ.
chmod 0644 "$target"
}
build_sshd_tunnel_candidate() {
local target=$1 host_port
{
printf 'Match User %s\n' "$TUNNEL_USER"
printf ' AuthorizedKeysFile %s/%%u\n' "$AUTHORIZED_KEYS_DIR"
echo ' AllowTcpForwarding local'
echo ' AllowStreamLocalForwarding no'
echo ' GatewayPorts no'
echo ' AllowAgentForwarding no'
echo ' X11Forwarding no'
echo ' PermitTTY no'
echo ' PermitTunnel no'
echo ' PermitUserRC no'
echo ' MaxSessions 0'
printf ' PermitOpen'
for host_port in "${PERMIT_OPENS[@]}"; do
printf ' %s' "$host_port"
done
printf '\nMatch all\n'
} > "$target"
chmod 0644 "$target"
}
build_main_config_candidate() {
local target=$1 allow_line user
local -a allow_users=()
[[ -f "$SSHD_MAIN_CONF" ]] || die "Не найден ${SSHD_MAIN_CONF}"
[[ ! -L "$SSHD_MAIN_CONF" ]] || die "${SSHD_MAIN_CONF} не должен быть симлинком"
! grep -Eq '^[[:space:]]*Match([[:space:]]|$)' "$SSHD_MAIN_CONF" \
|| die "${SSHD_MAIN_CONF} должен содержать только глобальные настройки, без Match"
python3 - "$SSHD_MAIN_CONF" "$target" "$TUNNEL_USER" <<'PY'
from pathlib import Path
import re
import sys
source = Path(sys.argv[1])
target = Path(sys.argv[2])
user = sys.argv[3]
lines = source.read_text(encoding="utf-8").splitlines(keepends=True)
matches = [
index for index, line in enumerate(lines)
if re.match(r"^\s*AllowUsers\s+", line)
]
if len(matches) != 1:
print(
f"{source}: ожидалась ровно одна глобальная директива AllowUsers, найдено {len(matches)}",
file=sys.stderr,
)
raise SystemExit(1)
index = matches[0]
tokens = lines[index].split()
if user not in tokens[1:]:
newline = "\n" if lines[index].endswith("\n") else ""
lines[index] = f"AllowUsers {' '.join(tokens[1:] + [user])}{newline}"
Path(target).write_text("".join(lines), encoding="utf-8")
PY
allow_line=$(awk '/^[[:space:]]*AllowUsers[[:space:]]+/ {print; exit}' "$target")
IFS=' ' read -r -a allow_users <<< "$allow_line"
((${#allow_users[@]} >= 2)) || die "AllowUsers не содержит пользователей"
for user in "${allow_users[@]:1}"; do
[[ "$user" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "Неподдерживаемый шаблон AllowUsers: ${user}; перечислите реальные локальные учётные записи"
getent passwd "$user" >/dev/null \
|| die "AllowUsers содержит несуществующего пользователя: ${user}"
done
chmod 0644 "$target"
}
verify_endpoint_from_host() {
local endpoint=$1
local host="${endpoint%%:*}"
local port="${endpoint##*:}"
if command -v nc >/dev/null 2>&1; then
nc -z -w 5 "$host" "$port" \
&& log "TCP ${endpoint} доступен" \
|| die "С VM2 нет TCP-доступа к ${endpoint}; сначала почините private network/firewall"
elif command -v timeout >/dev/null 2>&1; then
timeout 5 bash -c 'echo > "/dev/tcp/$1/$2"' _ "$host" "$port" 2>/dev/null \
&& log "TCP ${endpoint} доступен" \
|| die "С VM2 нет TCP-доступа к ${endpoint}; сначала почините private network/firewall"
else
log "nc и timeout не установлены; пропускаю TCP-проверку"
fi
}
verify_network_from_host() {
local endpoint
step "Проверка сетевой доступности разрешённых endpoint с VM2"
for endpoint in "${PERMIT_OPENS[@]}"; do
verify_endpoint_from_host "$endpoint"
done
}
atomic_install() {
local source=$1 target=$2 mode=$3
local temporary
temporary=$(mktemp "${target}.tmp.XXXXXX") || return 1
if ! install -o root -g root -m "$mode" "$source" "$temporary"; then
rm -f -- "$temporary"
return 1
fi
if ! mv -Tf -- "$temporary" "$target"; then
rm -f -- "$temporary"
return 1
fi
}
backup_file() {
local target=$1 name=$2 backup_dir=$3
if [[ -e "$target" || -L "$target" ]]; then
cp -a -- "$target" "${backup_dir}/${name}"
: > "${backup_dir}/${name}.existed"
fi
}
restore_file() {
local target=$1 name=$2 backup_dir=$3
rm -f -- "$target"
if [[ -f "${backup_dir}/${name}.existed" ]]; then
cp -a -- "${backup_dir}/${name}" "$target"
fi
}
rollback_transaction() {
local backup_dir=$1
log "Откат SSH-конфигурации и authorized_keys"
restore_file "$SSHD_MAIN_CONF" main.conf "$backup_dir"
restore_file "$SSHD_TUNNEL_CONF" tunnel.conf "$backup_dir"
restore_file "$AUTHORIZED_KEYS_FILE" authorized_keys "$backup_dir"
}
assert_effective_value() {
local config=$1 key=$2 expected=$3 actual
actual=$(awk -v key="$key" '$1 == key {$1=""; sub(/^ /, ""); print; exit}' <<< "$config")
if [[ "$actual" != "$expected" ]]; then
log "Эффективный sshd ${key}=${actual:-<empty>}, ожидалось ${expected}"
return 1
fi
}
verify_effective_sshd_config() {
local effective endpoint permit_open authorized_keys_value allow_user_found=false
local -a effective_permit_opens=()
effective=$(sshd -T -C "user=${TUNNEL_USER},host=localhost,addr=127.0.0.1") \
|| return 1
assert_effective_value "$effective" allowtcpforwarding local || return 1
assert_effective_value "$effective" allowstreamlocalforwarding no || return 1
assert_effective_value "$effective" allowagentforwarding no || return 1
assert_effective_value "$effective" x11forwarding no || return 1
assert_effective_value "$effective" gatewayports no || return 1
assert_effective_value "$effective" permittty no || return 1
assert_effective_value "$effective" permittunnel no || return 1
assert_effective_value "$effective" permituserrc no || return 1
assert_effective_value "$effective" maxsessions 0 || return 1
assert_effective_value "$effective" passwordauthentication no || return 1
assert_effective_value "$effective" kbdinteractiveauthentication no || return 1
assert_effective_value "$effective" pubkeyauthentication yes || return 1
authorized_keys_value=$(awk '
$1 == "authorizedkeysfile" {$1=""; sub(/^ /, ""); print; exit}
' <<< "$effective")
if [[ "$authorized_keys_value" != "${AUTHORIZED_KEYS_DIR}/%u" \
&& "$authorized_keys_value" != "$AUTHORIZED_KEYS_FILE" ]]; then
log "Эффективный AuthorizedKeysFile=${authorized_keys_value:-<empty>}, ожидался root-owned путь"
return 1
fi
permit_open=$(awk '
$1 == "permitopen" {
for (i = 2; i <= NF; i++) values = values " " $i
}
END {sub(/^ /, "", values); print values}
' <<< "$effective")
IFS=' ' read -r -a effective_permit_opens <<< "$permit_open"
if ((${#effective_permit_opens[@]} != ${#PERMIT_OPENS[@]})); then
log "Эффективный PermitOpen содержит неожиданный набор: ${permit_open:-<empty>}"
return 1
fi
for endpoint in "${PERMIT_OPENS[@]}"; do
if [[ " ${permit_open} " != *" ${endpoint} "* ]]; then
log "Эффективный PermitOpen не содержит ${endpoint}"
return 1
fi
done
if awk -v user="$TUNNEL_USER" '
$1 == "allowusers" {
for (i = 2; i <= NF; i++) if ($i == user) found = 1
}
END {exit !found}
' <<< "$effective"; then
allow_user_found=true
fi
if [[ "$allow_user_found" != "true" ]]; then
log "Эффективный AllowUsers не содержит ${TUNNEL_USER}"
return 1
fi
}
detect_ssh_service() {
if systemctl cat ssh.service >/dev/null 2>&1; then
printf 'ssh\n'
elif systemctl cat sshd.service >/dev/null 2>&1; then
printf 'sshd\n'
else
die "Не найден systemd-сервис ssh.service или sshd.service"
fi
}
prepare_authorized_keys_dir() {
local owner mode
if [[ -e "$AUTHORIZED_KEYS_DIR" || -L "$AUTHORIZED_KEYS_DIR" ]]; then
[[ -d "$AUTHORIZED_KEYS_DIR" && ! -L "$AUTHORIZED_KEYS_DIR" ]] \
|| die "${AUTHORIZED_KEYS_DIR} должен быть обычным каталогом, не симлинком"
owner=$(stat -c '%U:%G' "$AUTHORIZED_KEYS_DIR")
[[ "$owner" == "root:root" ]] \
|| die "${AUTHORIZED_KEYS_DIR} принадлежит ${owner}, ожидался root:root"
mode=$(stat -c '%a' "$AUTHORIZED_KEYS_DIR")
(( (8#$mode & 8#022) == 0 )) \
|| die "${AUTHORIZED_KEYS_DIR} доступен для записи группе или остальным: mode ${mode}"
else
install -d -o root -g root -m 0755 "$AUTHORIZED_KEYS_DIR"
fi
}
install_and_reload_sshd() {
step "Атомарная установка, проверка и reload sshd"
local work_dir=$1 main_candidate=$2 tunnel_candidate=$3 key_candidate=$4
local ssh_service
ssh_service=$(detect_ssh_service)
prepare_authorized_keys_dir
backup_file "$SSHD_MAIN_CONF" main.conf "$work_dir"
backup_file "$SSHD_TUNNEL_CONF" tunnel.conf "$work_dir"
backup_file "$AUTHORIZED_KEYS_FILE" authorized_keys "$work_dir"
if ! atomic_install "$main_candidate" "$SSHD_MAIN_CONF" 0644; then
rollback_transaction "$work_dir"
die "Не удалось установить ${SSHD_MAIN_CONF}; выполнен откат"
fi
if ! atomic_install "$tunnel_candidate" "$SSHD_TUNNEL_CONF" 0644; then
rollback_transaction "$work_dir"
die "Не удалось установить ${SSHD_TUNNEL_CONF}; выполнен откат"
fi
if ! sshd -t; then
rollback_transaction "$work_dir"
die "sshd -t не прошёл; исходная конфигурация восстановлена"
fi
if ! verify_effective_sshd_config; then
rollback_transaction "$work_dir"
die "Эффективная конфигурация sshd не прошла проверку; выполнен откат"
fi
if ! atomic_install "$key_candidate" "$AUTHORIZED_KEYS_FILE" 0644; then
rollback_transaction "$work_dir"
die "Не удалось установить ${AUTHORIZED_KEYS_FILE}; выполнен откат"
fi
if ! systemctl reload "$ssh_service"; then
rollback_transaction "$work_dir"
if sshd -t; then
systemctl reload "$ssh_service" \
|| log "КРИТИЧНО: не удалось reload исходной конфигурации ${ssh_service}"
else
log "КРИТИЧНО: исходная конфигурация после отката не проходит sshd -t"
fi
die "Reload ${ssh_service} не выполнен; конфигурация восстановлена"
fi
log "sshd reload выполнен; ключ хранится в root-owned ${AUTHORIZED_KEYS_FILE}"
}
print_usage() {
cat <<EOF
Готово.
Проверка с вашей рабочей машины (не закрывая текущую admin-сессию):
ssh -i ~/.ssh/han_vm2_tunnel -N \\
-L 16433:${PG_HOST}:${PG_PORT} \\
${TUNNEL_USER}@<VM2_PUBLIC_IP>
В другом терминале:
nc -zv 127.0.0.1 16433
Подключение к БД через туннель (пример):
psql "postgresql://<USER>:<PASSWORD>@127.0.0.1:16433/<DB>?sslmode=verify-full&sslrootcert=<PATH_TO_CA>"
Что запрещено для ${TUNNEL_USER}:
- sudo / shell / доступ к секретам приложения
- remote forwarding (-R), произвольный SOCKS, agent/X11
- forwarding на адреса вне PermitOpen
Политика источника ключа:
- CIDR: ${TUNNEL_SOURCE_CIDRS:-не задан}
- осознанно разрешён любой источник: ${TUNNEL_ALLOW_ANY_SOURCE}
EOF
}
main() {
local work_dir main_candidate tunnel_candidate key_candidate
require_root
require_commands
validate_tunnel_user
validate_source_policy
validate_cidrs "$TUNNEL_SOURCE_CIDRS"
validate_key_file "$TUNNEL_AUTHORIZED_KEY_FILE"
collect_permit_opens
verify_network_from_host
create_or_update_user
work_dir=$(mktemp -d /run/setup-tunnel-user.XXXXXX)
trap "rm -rf -- '$work_dir'" EXIT
main_candidate="${work_dir}/main.conf.candidate"
tunnel_candidate="${work_dir}/tunnel.conf.candidate"
key_candidate="${work_dir}/authorized_keys.candidate"
build_main_config_candidate "$main_candidate"
build_sshd_tunnel_candidate "$tunnel_candidate"
build_authorized_keys_candidate "$key_candidate"
install_and_reload_sshd \
"$work_dir" "$main_candidate" "$tunnel_candidate" "$key_candidate"
print_usage
}
main "$@"
@@ -0,0 +1,133 @@
Да, скрипт операционно идемпотентен: повторный запуск с тем же `TUNNEL_USER` приводит конфигурацию к тому же состоянию. Он повторно проверит настройки и выполнит reload SSH, поэтому не является строго no-op.
Нельзя менять `TUNNEL_USER` при повторном запуске: старый пользователь и его ключ автоматически не удаляются.
## Инструкция запуска
### 1. Создать ключ на рабочем компьютере
PowerShell:
```powershell
ssh-keygen -t ed25519 `
-f "$env:USERPROFILE\.ssh\han_vm2_tunnel" `
-C "han-vm2-db-tunnel"
```
Не перезаписывайте существующий ключ без запланированной ротации.
### 2. Передать ключ и скрипт на VM2
```powershell
scp -i "$env:USERPROFILE\.ssh\han_vm2_admin" `
"$env:USERPROFILE\.ssh\han_vm2_tunnel.pub" `
admin@<VM2_PUBLIC_IP>:/tmp/han_vm2_tunnel.pub
scp -i "$env:USERPROFILE\.ssh\han_vm2_admin" `
".\setup-tunnel-user.sh" `
admin@<VM2_PUBLIC_IP>:/tmp/setup-tunnel-user.sh
```
Подключиться к VM2:
```powershell
ssh -i "$env:USERPROFILE\.ssh\han_vm2_admin" admin@<VM2_PUBLIC_IP>
```
### 3. Установить временные файлы
На VM2:
```sh
sudo install -d -o root -g root -m 0700 /root/bootstrap
sudo install -o root -g root -m 0600 \
/tmp/han_vm2_tunnel.pub \
/root/bootstrap/tunnel.pub
sudo install -o root -g root -m 0700 \
/tmp/setup-tunnel-user.sh \
/root/setup-tunnel-user.sh
rm -f /tmp/han_vm2_tunnel.pub /tmp/setup-tunnel-user.sh
```
Текущую admin-сессию не закрывать до окончания проверки.
### 4. Запустить с ограничением по IP
Укажите внешний IP рабочей сети:
```sh
sudo env \
TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \
TUNNEL_SOURCE_CIDRS="<YOUR_PUBLIC_IP>/32" \
PG_HOST="192.168.0.210" \
PG_PORT="6433" \
bash /root/setup-tunnel-user.sh
```
Несколько разрешённых сетей:
```sh
TUNNEL_SOURCE_CIDRS="203.0.113.10/32,198.51.100.0/24"
```
Дополнительный endpoint:
```sh
EXTRA_PERMIT_OPEN="192.168.0.210:5433"
```
### 5. Осознанно разрешить любой источник
Только если доступ уже ограничен cloud SG/firewall:
```sh
sudo env \
TUNNEL_AUTHORIZED_KEY_FILE=/root/bootstrap/tunnel.pub \
TUNNEL_ALLOW_ANY_SOURCE=true \
PG_HOST="192.168.0.210" \
PG_PORT="6433" \
bash /root/setup-tunnel-user.sh
```
Одновременно задавать `TUNNEL_SOURCE_CIDRS` и `TUNNEL_ALLOW_ANY_SOURCE=true` нельзя.
### 6. Проверить туннель
На рабочем компьютере в отдельном PowerShell:
```powershell
ssh `
-i "$env:USERPROFILE\.ssh\han_vm2_tunnel" `
-N `
-o ExitOnForwardFailure=yes `
-o ServerAliveInterval=30 `
-L "127.0.0.1:16433:192.168.0.210:6433" `
tunnel@<VM2_PUBLIC_IP>
```
Адрес и порт после `-L` должны точно совпадать с `PermitOpen`.
Во втором терминале:
```powershell
Test-NetConnection 127.0.0.1 -Port 16433
```
Для PostgreSQL с проверкой TLS-имени:
```sh
psql "host=<DB_CERTIFICATE_NAME> hostaddr=127.0.0.1 port=16433 dbname=<DB> user=<USER> sslmode=verify-full sslrootcert=<PATH_TO_CA>"
```
### 7. Очистить bootstrap-файлы
После успешной проверки на VM2:
```sh
sudo rm -f /root/bootstrap/tunnel.pub
```
Рабочий ключ уже будет установлен в `/etc/ssh/authorized_keys/tunnel`.
+5
View File
@@ -12,3 +12,8 @@
- [`module-10-deployment-vm2.md`](module-10-deployment-vm2.md) — runbook ВМ2.
Guest API, frontend, Keycloak, SMS и Bitrix Open Lines local app находятся в [`VM1_app/documentation`](../../VM1_app/documentation/README.md). ВМ2 публикует на `443` только два exact CRM webhook; Safety доступен ВМ1 только через private HTTPS `:8443`.
Cutover caller выполняется по
[`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md)
и [fresh production runbook ВМ1](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md);
ВМ2 не предоставляет ВМ1 fallback на local stub.
@@ -2,7 +2,9 @@
> Статус: целевой runbook репозитория ВМ2.
> Общий контракт (VPC/SG, PG, S3, роли `deploy`, TLS процедура, порядок cutover) — [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
> ВМ1 — [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md). Не переносить команды ВМ1 и не шарить Compose/IAM/secrets.
> ВМ1 — [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md)
> и её [fresh production runbook](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md).
> Не переносить команды ВМ1 и не шарить Compose/IAM/secrets.
## 1. Границы
@@ -181,5 +183,8 @@ D-TBD5 Safety v2; D-TBD6 bitrix-sync cutover; performance gates уже в §2.
- Контракт: [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
- ВМ1: [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md).
- Указатель: [`module-10-deployment-runbook.md`](module-10-deployment-runbook.md).
- Исполняемый fresh production runbook ВМ1:
[`RUNBOOK.production.ru.md`](../../VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md).
- Исполняемый runbook ВМ2:
[`RUNBOOK.ru.md`](../codebase/services/deployment/RUNBOOK.ru.md).
- Safety / sync / nginx: [`module-05-message-safety.md`](module-05-message-safety.md), [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md), [`module-03-nginx-vm2.md`](module-03-nginx-vm2.md).