Реализация на отдельных двух машинах с протестированным взаимодействием по проверке сообщений
This commit is contained in:
@@ -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 1–9 → §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 1–9: 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 6–8
|
||||
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 1–9 → §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 1–9: 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`.
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user