Files
han-app/architectory/arch-03-docker-compose-blueprint.md
T

694 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# arch-03. Docker Compose blueprint
> Термины (имена бакетов S3, идентификаторы) — в [`arch-00-glossary.md`](arch-00-glossary.md). Контракт Message Safety Service — в [`arch-02-api-contracts.md`](arch-02-api-contracts.md), раздел «api-backend ↔ message-safety». Переменные окружения и настройки — в [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md). VM/SSH, OS-роли, секреты, systemd-деплой и hardening — в [`arch-06-service-hosting-security.md`](arch-06-service-hosting-security.md).
## Назначение
Этот документ описывает целевой Docker Compose контур для первой production-like среды. Он не заменяет будущий `docker-compose.yml`, но задает требования, которым он должен соответствовать.
Требования к безопасности на уровне приложения и данных — в [`arch-01-system-architecture.md`](arch-01-system-architecture.md), раздел **«Принципы безопасности»**. Настоящий документ описывает только инфраструктурную реализацию этих принципов в compose/nginx: TLS, маршрутизация, сетевые границы, rate limits на edge. Значения переменных окружения — в [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md). Дублировать прикладные требования (JWT, валидация, CORS в API, PII в логах и т.п.) здесь не нужно — они остаются в `arch-01`.
## Один root Compose project на каждую VM (обязательно)
Это зафиксированное архитектурное требование, а не рекомендация.
### Принцип независимого входа по VM
- ВМ1 и ВМ2 имеют по одному независимому root Compose project: `backend/docker-compose.yml` и `processing/docker-compose.yml`.
- Каждый project поднимается своим root-owned systemd-unit/deployment helper. Пользователь `deploy` запускает только конкретные units и не получает доступ к Docker daemon; канон — arch-06.
- Внутри одной VM её корневой `docker-compose.yml` — единственный источник правды. Cross-host Docker network и запуск одного Compose project на двух VM запрещены.
- **Nginx ВМ1** является публичной точкой входа только своего контура:
- `/api/*``api-backend` (REST и `WS /api/v1/realtime`; отдельный path `/realtime/*` **не** используется);
- `/auth/*``keycloak`;
- `/bitrix/*` (public: `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement`, `/health/*` для `bitrix-local-app`) → `bitrix-local-app`;
- exact `POST /callbacks/idgtl/sms``sms-service`; остальные методы и SMS paths не публикуются;
- web-сборка frontend или прокси на dev-сервер;
- `/internal/openlines/*`, `/internal/safety/*`, `/internal/sync/*`, `/internal/sms/*`, `/internal/notifications/*` **не публикуются** наружу.
- **Nginx ВМ2** независимо терминирует public HTTPS на отдельном host и публикует только exact Contact/alert webhook `bitrix-sync`. Отдельный private listener `8443` по internal CA маршрутизирует allow-listed Message Safety/internal paths.
- Между public route ВМ1 и ВМ2 нет reverse-proxy chain или fallback. Каждый nginx имеет собственные DNS, сертификат, rate limits и release lifecycle.
После Safety cutover root Compose ВМ1 не содержит local `message-safety`,
его Redis DB2 или `bitrix-sync`: это компоненты ВМ2. `api-backend` использует
`MESSAGE_SAFETY_URL=https://<private-vm2-name>:8443` и отдельный read-only CA
bind из root-owned staging-каталога, доступного фактическому UID/GID
`api-backend`. Validator обязан принимать private HTTPS hostname/SAN и
отклонять cross-host Docker DNS либо plaintext production URL. Наличие legacy
local Safety одновременно с remote URL является cutover error, а не
автоматическим fallback.
### Структура Compose через `include`
Каждый сервис описывается в собственном `docker-compose.yml` и подключается в root-файл своей VM директивой `include`:
```text
backend/
docker-compose.yml # root ВМ1
.env
nginx/
docker-compose.yml # описание сервиса nginx (или секция в корневом)
nginx.conf
conf.d/
certs/
.gitkeep
api-backend/
docker-compose.yml # описание сервиса api-backend
bitrix-local-app/
docker-compose.yml # описание сервиса bitrix-local-app
keycloak/
docker-compose.yml # описание сервиса keycloak (или секция в корневом)
observability/
docker-compose.yml # otel-collector и т.п.
processing/
docker-compose.yml # root ВМ2
nginx-internal/docker-compose.yml
message-safety/docker-compose.yml
bitrix-sync/docker-compose.yml
clamav/docker-compose.yml
redis/docker-compose.yml
observability/docker-compose.yml
```
Корневой `backend/docker-compose.yml` (принципиальная схема):
```yaml
name: han-chat
include:
- nginx/docker-compose.yml
- api-backend/docker-compose.yml
- bitrix-local-app/docker-compose.yml
- keycloak/docker-compose.yml
- sms-service/docker-compose.yml
- redis/docker-compose.yml
- observability/docker-compose.yml
networks:
public:
backend:
egress:
observability:
volumes:
redis-data:
nginx-certs:
```
Root Compose ВМ2 включает собственный nginx с public/private server blocks, Message Safety API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety и локальный OTEL Collector. Секреты, сети и volumes двух projects не общие.
### Правила для сервисных compose-файлов
- Сервисный `docker-compose.yml` описывает **только** сервис(ы) своего модуля: образ, build context, non-secret `environment` (через `${VAR}` из корневого `.env`), secret files/credentials, порты (только внутренние, кроме случаев ниже), `depends_on`, healthcheck, подключение к сетям `public`/`backend`/`observability` (объявленным в корневом файле).
- Сервисный файл **не объявляет** сети и volumes верхнего уровня — они объявляются в корневом `docker-compose.yml`. Сервис только ссылается на них через `networks:` / `volumes:` (external-стиль не нужен, т.к. `include` объединяет файлы в один проект).
- Публикация портов наружу (`ports:`) разрешена **только** для `nginx` (80/443). Все остальные сервисы используют `expose:` для внутренних портов и общаются через Docker-сети.
- `bitrix-local-app` не публикует `8080` на хост (даже на `127.0.0.1`) — он доступен `api-backend` и `nginx` через сеть `backend`/`public`. Ранее применявшийся `127.0.0.1:8080:8080` считаем устаревшим; проверки через curl на `127.0.0.1:8080` заменяются на `docker compose exec bitrix-local-app` или прокси через `nginx`.
- Каждый сервисный compose-файл должен запускаться и в составе корневого контура, и автономно (`docker compose -f bitrix-local-app/docker-compose.yml up`) для локальной разработки сервиса — при условии, что переменные окружения заданы. Для автономного запуска сервис может объявлять заглушки сетей/volumes, но в составе корневого контура они переопределяются общими.
### Обязательный container hardening
Для production-сервисов применяются требования [`arch-06-service-hosting-security.md`](arch-06-service-hosting-security.md):
- непривилегированный `user`;
- `security_opt: [no-new-privileges:true]`;
- `cap_drop: [ALL]` с точечным возвратом документированных capabilities;
- `read_only: true`, а writable paths — отдельные volume/tmpfs;
- запрет `privileged`, host network/PID/IPC и Docker socket;
- CPU/memory/PID limits, healthcheck и pinned image version/digest;
- только необходимые Docker networks и read-only bind mounts.
Если сервису нужен root, writable root filesystem, capability или host mount, исключение фиксируется в профильной спецификации вместе с риском и компенсирующей мерой.
### Практическая валидация non-root/read-only image
Для каждого pinned digest Compose фиксирует и проверяет:
- фактические UID/GID основного процесса и entrypoint;
- vendor entrypoint для non-root режима, если он отличается от root-варианта;
- полный список writable paths: generated config, runtime/socket, cache/temp,
logs и persistent state;
- отдельный volume/tmpfs для каждого writable path с явными
`uid/gid/mode`, размером и mount flags;
- bind file проверяется реальным read-test от container UID/GID и отказом
постороннему UID, а не только host `stat`;
- новый named volume для non-root consumer подготавливается idempotent
one-shot init service с минимальными capabilities; consumer использует
`depends_on: condition: service_completed_successfully`;
- healthcheck именно того процесса, который реально запущен в контейнере, и
только binaries, наличие которых доказано внутри exact pinned digest;
- restart semantics: успешный one-shot exit не должен превращаться в
бесконечный restart/download loop.
Tmpfs скрывает ownership каталога из image, поэтому одного корректного
`USER`/`chown` в Dockerfile недостаточно: ownership задаётся на самом tmpfs.
Ошибка `read-only file system` исправляется добавлением минимального writable
mount, а не `read_only: false`, root, `privileged` или broad capabilities.
Compose file secrets с bind-backed `file:` могут игнорировать декларативные
`uid/gid/mode`. Их фактические host permissions создаёт secret materializer;
rollout проверяет owner/mode из контейнера и с host, не полагаясь на YAML.
`root:root 0600` несовместим с non-root consumer; dedicated group/ACL должен
совпадать с фактическим container GID и не давать доступ постороннему UID.
При сборке images необхоидмо добавлять нормализацию CRLF→LF
(например, RUN sed -i 's/\r$//' <directory/service-name> \
&& /bin/sh -n <directory/service-name>) и использовать проверку синтаксиса entrypoint
### Команды разработки
```text
docker compose up -d
docker compose logs -f nginx
docker compose logs -f api-backend
docker compose logs -f message-safety
docker compose logs -f bitrix-sync
docker compose logs -f bitrix-local-app
docker compose exec api-backend alembic upgrade head
docker compose exec api-backend pytest
docker compose exec api-backend ruff check .
docker compose exec api-backend ruff format .
```
## Сервисы
### nginx ВМ1 и ВМ2
На каждой VM работает собственный nginx в независимом root Compose. ВМ1 обслуживает frontend/API/auth/Open Lines/SMS; ВМ2 напрямую принимает CRM webhook и отдельно предоставляет private Message Safety ingress. Публичный трафик ВМ2 не проксируется через ВМ1.
Требования:
- публикует наружу только `80` и `443` (см. политику HTTP ниже);
- принимает внешний HTTPS-трафик;
- выполняет TLS termination на reverse proxy; внутренний HTTP между контейнерами — только в закрытой Docker-сети `backend`;
- non-root nginx получает writable tmpfs только для `/etc/nginx/conf.d`,
`/var/cache/nginx`, `/var/run` и `/tmp`; tmpfs задаёт явные UID/GID/mode и
`nofile` согласован с `worker_connections`;
- если image entrypoint выполняет `envsubst`, output directory обязан быть
writable целевому UID, а основной `nginx.conf` подключает конкретный
generated file, чтобы отсутствующий результат не дал ложный успешный
`nginx -t` через wildcard include;
- pre-start config test выполняет реальный image entrypoint. До запуска
upstream-контейнеров их host variables временно подменяются loopback IP
только в test container; production Compose сохраняет service DNS names;
- после readiness upstream выполняется повторный `nginx -t` с production
Docker DNS names и контролируемый reload/recreate nginx. Первый ordered
start и `depends_on: service_started` не гарантируют, что hostname уже
резолвится;
- при recreate/смене IP upstream действует та же post-ready reload policy либо
используется явно протестированный dynamic resolver; stale IP/DNS в
загруженной nginx config недопустим;
- **политика HTTP/HTTPS по доменам** (каноническое правило — [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Принципы безопасности»):
- **веб-домен** (frontend, SPA, статика): `listen 80` допускается **только** для безусловного редиректа `301`/`308` на HTTPS; обработка бизнес-логики по HTTP запрещена;
- **API-домен** (если выделен отдельный host, напр. `api.example.ru`): **не** слушает порт `80`; только `listen 443 ssl`; HTTP-запросы к API-домену недоступны;
- **единый домен MVP** (напр. `tohin.ru` с путями `/api/*`, `/auth/*`, web): считается веб-доменом; порт `80` — только redirect на HTTPS для всего server block; после редиректа весь пользовательский трафик — HTTPS;
- **auth** на том же host, что API (`/auth/*`): следует политике host (redirect-only на :80 или HTTPS-only для выделенного API-host);
- **Bitrix callbacks** (`/bitrix/*`, `/bitrix/sync/*`): только HTTPS; порт `80` не обслуживает эти location — только redirect;
- маршрутизирует `/api/*` в `api-backend` (включая WebSocket upgrade для `/api/v1/realtime`);
- маршрутизирует `/auth/*` в `keycloak` или проксирует отдельный auth-домен;
- маршрутизирует публичные `/bitrix/*` endpoint в `bitrix-local-app`;
- nginx ВМ2 маршрутизирует только exact `/bitrix/sync/webhook/contact` и `/bitrix/sync/webhook/alert` в локальный `bitrix-sync`;
- маршрутизирует только exact `POST /callbacks/idgtl/sms` в `sms-service:8080`; применяет HTTPS, подтверждённый allowlist source IP Direct, body/rate limits и redaction Basic Authorization;
- закрывает `/internal/*` (в т.ч. `bitrix-local-app`, `message-safety`, `bitrix-sync` ops) от публичного доступа — только private network Docker/VPC;
- **не публикует** `message-safety` наружу;
- **production-like / production**: отдаёт **статическую сборку Expo web** из volume или каталога (`/usr/share/nginx/html` или аналог); `index.html` + assets, SPA fallback `try_files $uri /index.html`;
- **local dev** (опционально): при `FRONTEND_DEV_PROXY_ENABLED=true` проксирует `/` на Expo dev server (`EXPO_DEV_SERVER_URL`, напр. `http://host.docker.internal:8081`);
- передает upstream-сервисам `Host`, `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Request-ID`;
- если входящий запрос **без** `X-Request-ID`, nginx **генерирует** UUID и устанавливает заголовок до proxy_pass (I3);
- задает разумные `proxy_connect_timeout`, `proxy_read_timeout`, `client_max_body_size`;
- для `POST /api/v1/dialogs/*/messages` `proxy_read_timeout` должен быть не меньше `MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s`, чтобы nginx не обрывал sync-wait при async file scan;
- применяет edge rate limits для auth, API и download endpoints;
- ограничивает частоту соединений и размер тела запроса;
- разрешает только TLS 1.2/1.3 и запрещает слабые шифры;
- добавляет HSTS и базовые security headers;
- скрывает заголовки, раскрывающие внутренние технологии;
- кэширует публичные endpoint настроек и контента;
- не проксирует наружу managed PostgreSQL, `redis`, `otel-collector` (БД вне compose, в VPC);
### api-backend
Python FastAPI backend.
Требования:
- запускается после доступности managed PostgreSQL, `keycloak`, `redis`;
- применяет non-secret настройки из `.env` и runtime secrets из явно смонтированных secret files;
- отдает `/health/live` и `/health/ready`;
- корректно работает за reverse proxy и доверяет proxy headers только от `nginx`;
- применяет API-level rate limits с состоянием в Redis;
- вызывает message safety pipeline для сообщений до отправки в Open Lines;
- вызывает `bitrix-local-app` для отправки сообщений в Open Lines;
- принимает forward нормализованных событий оператора от `bitrix-local-app`;
- поддерживает realtime endpoint для сообщений оператора;
- работает с Selectel S3 для файлов и документов;
- обслуживает Notification Center G/P и Internal Create/Cancel; token каждого продюсера передаётся через env/secret, в App DB хранится только hash;
- имеет отдельные процессы expire job (ежедневно 00:01 UTC, advisory lock) и cleanup upload drafts/S3; они используют тот же immutable image и не публикуют порты;
- экспортирует traces/logs в `otel-collector`;
- не хранит состояние внутри контейнера.
### message-safety
Отдельный сервис ВМ2 для проверки исходящих сообщений пользователя. HTTP-контракт — в [`arch-02-api-contracts.md`](arch-02-api-contracts.md), раздел «api-backend ↔ message-safety».
Требования:
- запускается после доступности managed PostgreSQL (схема `message_safety`) и Redis Safety;
- сам Message Safety наружу не публикуется; api-backend обращается через private listener nginx ВМ2 по HTTPS;
- отдаёт `/health/live` и capability-aware `/health/ready`: PostgreSQL/config — core gate, Redis/workers/S3/DNS влияют на отдельные capabilities; в MOCK normal capabilities показываются как `bypassed`, mode — `degraded`;
- target endpoints: `POST /internal/safety/v2/messages/check`, `GET /internal/safety/v2/messages/tasks/{task_id}` (private HTTPS + `X-Service-Token`);
- read-only доступ к S3-quarantine (отдельный access key без прав записи);
- использует отдельную схему `message_safety` в managed PostgreSQL и отдельный DB-user;
- runtime role читает immutable active `message_safety.config_versions`; создавать/активировать config может только отдельный migration/config-admin job;
- использует локальный Redis Safety только для hot cache/rate/wakeup; PostgreSQL владеет task queue/leases;
- запускает async workers для file scan из S3-quarantine;
- API container получает read-only
`/etc/han-chat/message-safety-mode.env` с host contract
`root:han-message-safety 0640` и GID `10001`; менять его и перезапускать
stack может только fixed helper, разрешённый `deploy` через exact-argument
sudoers;
- экспортирует traces/logs в `otel-collector`;
- таймауты: POST check 5 s, GET task 2 s, file scan 60 s (см. [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), переменные `MESSAGE_SAFETY_*`).
### ClamAV на ВМ2
`clamd` и `freshclam` используют один immutable image digest, но разные
security-профили:
- оба запускаются через vendor `init-unprivileged`, а не root entrypoint;
- `clamd` читает volume signatures read-only, не подключён к signature CDN и
имеет healthcheck daemon socket;
- `freshclam` один пишет в signatures и имеет только разрешённый egress к CDN;
- `/run/clamav` — отдельный runtime volume, `/var/log/clamav` и `/tmp`
ограниченные tmpfs с UID/GID ClamAV;
- `freshclam` работает как foreground daemon с заданным interval; inherited
healthcheck `clamd` отключён, потому что updater не поднимает daemon socket;
- работоспособность updater подтверждается состоянием `Up`, отсутствием
restart loop и отдельным контролем возраста/signature version, а не
искусственным container healthcheck.
- Архитектурная верхняя граница допустимого возраста signatures — `720` часов
(30 дней); активный seed-порог `max_signature_age_hours``240` часов
(10 дней), то есть строже предельного значения.
Смена digest ClamAV требует повторной проверки entrypoint, UID/GID, writable
paths, `clamd` health и фактического обновления signatures. Нельзя менять
только tag/digest, считая security contract image неизменным.
### bitrix-sync
Python API/worker service ВМ2 для durable двусторонней синхронизации App DB ↔ Bitrix24 CRM. Каноническая постановка — [`../modules/module-07-bitrix-sync.md`](../modules/module-07-bitrix-sync.md).
Требования:
- запускается при доступном managed PostgreSQL; Redis не является зависимостью sync;
- читает задачи из `han_app.sync_queue` (заполняется триггерами App DB);
- владеет схемой `bitrix_sync` и имеет только точечные GRANT на queue/profile/mapping в `han_app`;
- выполняет durable workflows `contact.map_or_create`, `contact.update`, `contact.deactivate` и административный `contact.rebind`;
- принимает `POST /bitrix/sync/webhook/contact` и `/bitrix/sync/webhook/alert`, durable сохраняет до `2xx`;
- выполняет Contact/alert reconciliation на случай потери обычного webhook;
- при записи в App DB от Bitrix использует `SET LOCAL han.sync_suppress='true'`;
- использует Bitrix `batch`, общий token bucket и bounded in-flight; default 2 HTTP requests/sec;
- поддерживает leases/fencing, graceful shutdown, retry до 24 часов, technical DLQ и business alerts;
- не блокирует пользовательский API при ошибках Битрикс24;
- не участвует в OTP-flow, не создаёт `UserIdentity`/`ClientProfile`;
- **не участвует** в hot path чата Open Lines;
- принимает публичный CRM webhook после TLS termination/rate limit на собственном nginx ВМ2; ВМ1 в route не участвует;
- включается/отключается флагом **`BITRIX_SYNC_ENABLED`** в `.env` (default `true`): при `false` сервис стартует в no-op/degraded режиме, но не обрабатывает `sync_queue` и не выполняет синхронизацию с Bitrix24 CRM.
### bitrix-local-app
Локальное приложение Bitrix24 и custom connector `han_mobile_app`.
Требования:
- публикует наружу только `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement`, `/health/live`, `/health/ready`;
- принимает `ONAPPINSTALL` и `ONIMCONNECTOR*` события от Bitrix24;
- регистрирует и активирует connector `han_mobile_app` для открытой линии 8;
- хранит OAuth-токены Bitrix24, inbox событий и `dialog_sessions` в managed PostgreSQL, схема `bitrix_local`;
- предоставляет internal API `POST /internal/openlines/v1/messages` и `GET /internal/openlines/v1/dialogs/{external_chat_id}` для api-backend;
- защищает internal API через `Authorization: Bearer {BITRIX_INTERNAL_API_TOKEN}`;
- forward-ит нормализованные события Open Lines в API, если задан `BITRIX_API_FORWARD_URL`;
- не хранит бизнес-данные приложения и не пишет напрямую в App DB.
### Managed PostgreSQL
**Во всех средах** (production, production-like, local dev) данные хранятся в **managed PostgreSQL** провайдера. Контейнер PostgreSQL в Docker Compose **не используется** — ни для production, ни для локальной разработки.
Прикладные данные, Keycloak, `bitrix-sync`, `bitrix-local-app` и `message-safety` подключаются к одной managed базе по URL из `.env` (`HAN_PG_HOST`, `HAN_PG_PORT`, `HAN_PG_DATABASE` и схемо-специфичные `*_DATABASE_URL`).
Требования:
- подключение только из приватной сети VPC (VM → managed PostgreSQL);
- одна managed база: схемы `han_app`, `bitrix_sync`, `message_safety`, `bitrix_local`, `keycloak`, `sms`;
- отдельные DB-пользователи с доступом только к своей схеме; исключение: `bitrix_sync_user` дополнительно имеет column/table GRANT на `han_app.sync_queue`, чтение необходимых identity/profile columns и controlled update CRM-master profile fields согласно module-07 §13; mapping/rebind находятся в собственной schema `bitrix_sync`;
- TLS к managed PostgreSQL обязателен;
- миграции Alembic выполняются отдельным controlled job с migration URL,
который не попадает в runtime services;
- временные cross-schema `USAGE`/`SELECT` выдаёт owner/DB administrator на
конкретные объекты до migration gate и отзывает после успешного commit;
migration чужой схемы не выполняет `REVOKE`/`ALTER` её объектов;
- release image сохраняет все уже использованные Alembic revision IDs, в том
числе legacy/no-op baseline, чтобы `upgrade head` не требовал ручного
`stamp` production DB;
- бэкапы и PITR — на стороне провайдера.
### keycloak
Identity provider. **Обязателен** в compose-контуре с первого запуска.
Требования:
- отдельный realm для приложения;
- отдельный frontend client с PKCE (обязателен);
- confidential backend client — **optional** (не используется в hot path MVP; S2S между сервисами — service tokens);
- публичный issuer должен соответствовать HTTPS URL, видимому frontend-приложению;
- включены proxy settings для работы за `nginx`;
- импорт realm в local/dev;
- использует managed PostgreSQL, схема `keycloak` (см. раздел «Managed PostgreSQL» выше);
- OTP mock / SMS SPI — см. arch-04; real mode вызывает `sms-service` по сети `backend`, а единственный утверждённый внешний вызов Keycloak через `egress` — server-side validation Yandex SmartCaptcha;
- healthcheck;
- взаимодействия — [`arch-02-api-contracts.md`](arch-02-api-contracts.md), «Frontend ↔ Keycloak», и [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Keycloak».
### sms-service и sms-worker
- `sms-service`: networks `backend`, `observability` и `egress` только если тот же process принимает callback и выполняет worker; `expose: 8080`, без host `ports`.
- При отдельном `sms-worker`: networks только `egress`, `observability` и доступ к managed PG; HTTP port не exposed/published.
- Оба используют `SMS_DATABASE_URL` к schema `sms`; только worker получает `IDGTL_SMS_API_KEY`.
- Callback credentials получает receiver для проверки и worker для формирования callback URL; Keycloak получает только `KEYCLOAK_SMS_SERVICE_TOKEN`.
- `sms-service` применяет собственные versioned migrations/seed; DDL-on-start запрещён. Readiness проверяет DB/schema, active approved `auth_otp` template, sender и API-key configuration.
- Ожидание Direct до 70 секунд происходит только в worker. `uncertain` не retry-ится автоматически; provider outage не создаёт restart loop и не отменяет active Keycloak challenge.
### redis
Кэш, rate limiting, coordination (не единственное хранилище бизнес-событий).
Требования:
- не использовать как единственное надежное хранилище бизнес-событий;
- хранить счетчики API-level rate limits и idempotency keys (`api-backend`);
- поддерживать TTL для лимитных и idempotency ключей;
- **не** хранить OTP counters для `api-backend` (OTP — зона Keycloak/SPI);
- sync_queue, leases, limiter coordination и durable wake-up fallback хранятся в PostgreSQL; `LISTEN/NOTIFY` — только optimization, Redis sync-service не использует;
- разделение DB index (I4): см. arch-04 (`REDIS_URL`, `MESSAGE_SAFETY_REDIS_URL`).
### otel-collector
Принимает telemetry от сервисов.
Требования:
- OTLP HTTP/gRPC receiver;
- экспорт traces/logs в stdout или платформенный collector;
- единые resource attributes: `service.name`, `deployment.environment`.
## Networks
Рекомендуемые сети:
- ВМ1 `public`: edge nginx, Keycloak proxy и frontend entrypoint.
- ВМ1 `backend`: `api-backend`, `bitrix-local-app`, Keycloak, SMS API и Redis DB0/DB1.
- ВМ2 `backend`: nginx, Safety API/worker, `bitrix-sync`, `clamd` и Redis Safety.
- `egress` подключается только к процессам с назначением: `freshclam` → signature CDN; `bitrix-sync` → утверждённый Bitrix portal; Safety worker → S3/PG/DNS; collector → private SigNoz. Общего internet egress у Safety API/clamd/Redis нет.
- `observability` существует отдельно на каждой VM и ведёт в её local collector.
Базы данных, Redis, OTLP receivers и internal service ports не публикуются. Cross-host calls идут через private network, точные SG и TLS.
## Volumes
Минимальные volumes:
- ВМ1: Redis DB0/DB1 data, public TLS/ACME, local OTEL queue;
- ВМ2: Redis Safety data (rebuildable), ClamAV signatures и runtime,
internal TLS secrets, local OTEL queue.
Public TLS и ACME на ВМ2 — не named volumes: Compose монтирует read-only host
staging `/var/lib/han-chat/public-tls` и ACME webroot
`/var/lib/han-chat/acme`. Internal TLS certificate/key передаются отдельными
Compose secrets и не объединяются с public TLS.
Данные PostgreSQL **не** хранятся в Docker volumes — только managed PostgreSQL вне compose.
Persistent volume используется только для state, переживающего recreate.
Generated config, PID/socket, cache и logs без retention размещаются в
ограниченных tmpfs. Один writable volume не объединяет config/executable со
state.
Local OTEL queue на каждой VM использует отдельный persistent named volume.
Перед collector запускается `otel-queue-init`: ограниченный one-shot service
выставляет mount root в `10001:10001 0700`, не имеет сети/secrets и завершается
до старта collector. Collector остаётся non-root; запуск collector от root,
`chmod 0777` и надежда на автоматическое наследование ownership из image
запрещены.
## Переменные окружения
Каждая VM имеет свой allow-listed non-secret env manifest. Секреты доставляются отдельными service files согласно arch-04/06; общий env/secret bundle двух VM запрещён.
Для smoke-продюсера обязателен `NOTIFICATIONS_TOKEN_PRODUCER_TEST`; это runtime secret, а не `.env`/`app_settings`. Compose монтирует его только `api-backend` и notification workers. Зарегистрированные entrypoints: `han-notification-expire-worker` и `han-notification-draft-cleanup-worker`; выдуманный command без project script в deployment запрещён.
## HTTPS и TLS
Соответствует [`arch-01-system-architecture.md`](arch-01-system-architecture.md), «Принципы безопасности» (HTTPS, TLS, HSTS). Инфраструктурная реализация:
### Домены и HTTP
| Host | Порт 80 | Порт 443 | Примечание |
|---|---|---|---|
| Веб-домен (frontend) | только `301`/`308` → HTTPS | HTTPS, бизнес-логика | MVP: `tohin.ru`; staging/dev может использовать отдельный host |
| API-домен (если выделен) | **не слушает** | только HTTPS | Post-MVP: `api.example.ru` |
| Bitrix callbacks (`/bitrix/*`, `/bitrix/sync/*`) | не обслуживает API; только redirect на том же host | HTTPS | webhook и install URL |
Правила:
- все внешние пользовательские соединения — **HTTPS**;
- HTTP допускается **только** на веб-домене как вход для редиректа на HTTPS;
- для **выделенного API-домена** HTTP **не допускается** (нет listener на :80);
- при едином домене MVP redirect на :80 применяется ко всему host, включая `/api/*` и `/auth/*`, после редиректа — только HTTPS.
### TLS и заголовки
- cookies в web-клиенте: `Secure`, `HttpOnly`, корректный `SameSite`;
- OIDC redirect URI в Keycloak — HTTPS;
- `KEYCLOAK_PUBLIC_URL`, issuer и frontend auth discovery URL совпадают по схеме, host и path;
- backend формирует внешние ссылки с учётом `X-Forwarded-Proto=https`;
- HSTS включается в production-like среде **после** проверки доменов и сертификатов;
- TLS 1.0/1.1 запрещены; минимум TLS 1.2, предпочтительно TLS 1.3;
- слабые шифры запрещены на уровне `nginx`;
- `nginx` скрывает `Server`, `X-Powered-By` и аналогичные технологические заголовки;
- security headers: `Strict-Transport-Security`, `X-Content-Type-Options`, `Referrer-Policy`, `Content-Security-Policy` для web-приложения;
- инструкция по установке всегда открывается новой вкладкой, поэтому CSP SPA задаёт `frame-src 'none'`; allow-list iframe для инструкций отсутствует;
- секретный ключ сертификата не коммитится в репозиторий;
- использовать сертификаты доверенного CA; автоматизировать выпуск и продление (Let's Encrypt + reload `nginx`);
- non-root nginx не монтирует root-only дерево Let's Encrypt целиком:
root deploy hook атомарно копирует только `fullchain.pem` и `privkey.pem` в
host staging `root:<dedicated-tls-group>` (`0750`, файлы `0640`), а Compose
монтирует staging read-only;
- reload после renewal выполняется только после `openssl` certificate/key
match и полного `nginx -t`; internal TLS PEM также проверяется на raw PEM,
отсутствие literal `\n`/double-base64 и совпадение ключа;
- закрыть прямой доступ к внутренним портам контейнеров извне.
## Nginx routing для Bitrix24 Local App
`nginx` должен поддерживать отдельные server/location rules для `bitrix-local-app`.
Рекомендуемая схема:
- **веб-домен** (MVP: `tohin.ru`): `/api/*` (REST + WS realtime), `/auth/*`, web frontend; `:80` → redirect HTTPS; `:443` — TLS + маршрутизация;
- **выделенный API-домен** (post-MVP, опционально): отдельный `server { listen 443 ssl; ... }` **без** `listen 80`; только `/api/*`;
- для `location` WebSocket (`/api/v1/realtime`): `proxy_http_version 1.1`, `Upgrade`/`Connection` headers, увеличенный `proxy_read_timeout`;
- домен или path `/bitrix/*``bitrix-local-app`; `/bitrix/sync/*``bitrix-sync`;
- `GET/POST /bitrix/handler` и `GET/POST /bitrix/install` доступны публично для Bitrix24;
- `/bitrix/placement` доступен публично как заглушка UI настроек коннектора;
- `/health/live` и `/health/ready` для `bitrix-local-app` доступны только там, где это нужно для healthcheck и проверки Bitrix form URL;
- `/internal/openlines/v1/*` не публикуется наружу или защищается allowlist/private network плюс `Authorization: Bearer {BITRIX_INTERNAL_API_TOKEN}`;
- для `/bitrix/*` callbacks кэширование отключено;
- для `/bitrix/*` callbacks включены отдельные rate limits, но они не должны блокировать легитимные webhook-повторы Bitrix24.
## Nginx routing для bitrix-sync (CRM webhooks)
Публичный nginx ВМ2 маршрутизирует только два exact webhook CRM sync в локальный `bitrix-sync`:
- `POST /bitrix/sync/webhook/contact` — изменение Contact;
- `POST /bitrix/sync/webhook/alert` — изменение элемента smart process конфликтов;
- nginx до proxy проверяет source IP по version-controlled `BITRIX_WEBHOOK_ALLOWED_CIDRS`; автоматическое расширение allow-list запрещено;
- штатный робот передаёт отдельный Contact/alert receiver token в query и form-urlencoded document/auth fields; token, query и body не попадают в logs/traces;
- document/entity/domain/member fields проверяются в `bitrix-sync`; local app/event handler для CRM sync не используется;
- кэширование отключено; source IP/body/method/rate limits применяются до private proxy; IP rejects экспортируются в telemetry без IP label;
- при sync disabled/cutover route закрыт либо возвращает retryable `503`, а не `202 ignored`;
- `BITRIX_SYNC_ENABLED=false` требует активный edge allow-list только с
`deny all;`; наличие `allow` при disabled и отсутствие reviewed `allow` при
enabled являются preflight error;
- `/internal/sync/v1/*` не публикуется наружу (только internal network + `BITRIX_SYNC_SERVICE_TOKEN`).
## Rate limits и защита от abuse
Rate limits должны быть распределены по двум слоям.
`nginx`:
- ограничивает частоту запросов до попадания в API;
- держит отдельные зоны лимитов для `/auth`, `/api`, public endpoints, fallback polling, download endpoints, чтения/действий/загрузок Notification Center;
- ограничивает `client_max_body_size`;
- ограничивает загрузку файлов лимитом 5 МБ; `client_max_body_size` должен быть чуть выше бизнес-лимита для учета overhead запроса;
- применяет `limit_req` для endpoint авторизации и fallback polling;
- для публичных endpoint использует лимит не выше 60 запросов в минуту с одного IP, если настройки не говорят иначе;
- возвращает `429` при превышении лимитов;
- не должен использоваться для сложных пользовательских правил, завязанных на `user_id`.
API:
- применяет лимиты после проверки JWT;
- считает лимиты по `user_id`, IP, route, dialog id и service client;
- хранит быстрые счетчики в Redis;
- пишет значимые превышения в audit/App DB;
- возвращает `Retry-After`, если клиент может повторить запрос позже.
Проверка сообщений на prompt injection и вредоносные действия не должна выполняться в `nginx`: это задача отдельного сервиса `message-safety`, вызываемого из `api-backend` (см. [`arch-02-api-contracts.md`](arch-02-api-contracts.md)).
## WAF
WAF можно подключить внешним слоем перед `nginx` без изменения бизнес-кода, если соблюдены требования:
- `nginx` и API корректно работают с цепочкой proxy headers и доверяют real IP только от доверенных прокси;
- CORS разрешает только доверенные домены;
- публичные endpoint имеют rate limits и кэширование даже без WAF;
- схема TLS termination согласована с тем, где завершается TLS: WAF/CDN, load balancer или `nginx`;
- WAF не должен подменять тело запросов и ответы API без явной необходимости.
WAF не заменяет обязательные лимиты, валидацию схем, авторизацию и аудит внутри приложения.
## Публичные endpoint
`GET /api/v1/public/app-config` и `GET /api/v1/public/content` являются публичными, поэтому для них обязательны:
- `limit_req` на уровне `nginx`, базово 60 запросов в минуту с одного IP;
- агрессивное кэширование на уровне `nginx` или CDN;
- заголовок `Cache-Control: public, max-age=3600`;
- строгая DTO-схема ответа на backend, без сериализации всех строк таблицы настроек;
- CORS только для доверенных доменов приложения;
- отсутствие секретов, внутренних URL, service tokens и приватных feature flags в ответе.
Подробнее — [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), разделы «Публичный config endpoint» и «Публичный content endpoint».
## Healthchecks
Минимальные проверки:
- `nginx`: на веб-домене — `301` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` — HTTP 200/301 и успешная TLS handshake;
- `api-backend`: `/health/live` проверяет процесс; `/health/ready` проверяет PostgreSQL `han_app`, Redis DB0/DB1, JWKS/discovery Keycloak и S3 permissions. Недоступность remote Message Safety отражается как degraded dependency и блокирует только send path, но не readiness read API;
- `message-safety`: `/health/ready` возвращает process/core status и capability map `text|links|files|worker`; ClamAV/S3 не выключают text, DNS не выключает text без ссылок, Redis hot cache не является core gate;
- `bitrix-sync`: `/health/live` проверяет процесс; `/health/ready` проверяет validated config/secrets, PostgreSQL/grants, worker/limiter state и CRM webhook config; invalid credential/config даёт not-ready, краткая CRM outage — degraded по stale policy; при `BITRIX_SYNC_ENABLED=false` ready возвращает not-ready `sync_disabled`;
- `bitrix-local-app`: `/health/live` проверяет процесс; `/health/ready` показывает PostgreSQL, OAuth-токены после установки приложения, connector activation и возможность forward в API при включённом `BITRIX_API_FORWARD_URL`;
- `keycloak`: health endpoint Keycloak; readiness — подключение к managed PostgreSQL;
- `sms-service`: live — процесс; ready — schema/migrations, active approved template, sender/API key; Direct доступность — отдельный dependency status, не причина restart loop;
- `redis`: `redis-cli ping`;
Наружу через `nginx` публикуются только health endpoint, которые нужны Bitrix24 install/callback validation или внешнему мониторингу. Internal services (`message-safety`, internal `bitrix-sync`, Redis, otel) проверяются только из Docker/VPC-сети.
Healthcheck считается валидным только после запуска внутри exact production
image digest с теми же `user`, `read_only`, mounts и dropped capabilities.
Команда не может предполагать наличие `curl`, `wget`, shell или package,
которого нет в image/SBOM; `ExitCode 127` является дефектом release, а не
`unhealthy` приложения. Для minimal nginx предпочтителен гарантированно
доступный native binary/config test либо специально включённый и проверенный
client.
Healthcheck не копируется между разными commands одного image без проверки.
Если updater не запускает daemon, daemon-socket healthcheck для него
отключается. Freshness данных контролируется отдельной метрикой/проверкой
timestamp и версии, а `restart: unless-stopped` применяется только к
долгоживущему foreground process.
## Порядок запуска
Общие prerequisites: managed PostgreSQL доступна из private network,
host-side secrets/TLS materialized, controlled migrations и seed завершены.
ВМ2 запускается в порядке:
1. `redis-safety`, `otel-queue-init`, затем local `otel-collector`;
2. `freshclam`, затем `clamd` до состояния healthy;
3. Message Safety API/worker и `bitrix-sync`;
4. nginx — последним, после успешного config test;
5. private HTTPS ВМ1→ВМ2 и capability health проверяются до cutover.
ВМ1 запускается в порядке:
1. Redis, `otel-queue-init`, затем `otel-collector`;
2. Keycloak, `sms-service`/worker, `api-backend` и `bitrix-local-app` с их
readiness-зависимостями;
3. notification expire/cleanup workers после готовности `api-backend`;
4. nginx — последним.
Порядок rollout SMS подробнее задаёт module-11/module-10. Зависимости запуска не образуют цикл: Keycloak стартует при недоступном `sms-service`; это блокирует только новые real-mode orders, а verify уже active challenges продолжается по snapshot.
`depends_on` не заменяет проверку готовности. Для edge применяются три слоя:
ordered start, readiness dependencies и post-ready config test/reload с
production DNS names. Сервисы должны уметь ждать зависимости или корректно
деградировать. `api-backend` может стать ready для read API до ВМ2, но send
endpoint обязан fail-closed при недоступной требуемой capability Safety.
## Развёртывание на ВМ1 и ВМ2
1. ВМ1, ВМ2, SigNoz и managed PostgreSQL находятся в одной private network/VPC; ВМ1 и ВМ2 имеют независимые public DNS/TLS ingress.
2. Managed PostgreSQL не имеет public IP; SG разрешает каждой VM только нужные DB roles/schemas.
3. На каждой VM отдельный root-owned systemd unit выполняет её root Compose; `deploy` не входит в `docker`.
4. Public nginx ВМ2 публикует `80/443`; `80` используется только для ACME/redirect, `443` — только exact CRM webhook. Private `8443` разрешён только от SG ВМ1 и ops для Message Safety/internal access.
5. Deploy/cutover ВМ2 не требует изменения public routes ВМ1. Для `bitrix-sync` rollback закрывает webhook routes на nginx ВМ2 либо возвращает retryable `503`, останавливает claims и сохраняет durable tasks/mapping; возврат к фиктивному `202 ignored` запрещён.
Host firewall обеих VM учитывает post-DNAT semantics `DOCKER-USER`: policy
published nginx ports сопоставляет original host destination через conntrack,
как определено в arch-06. Проверка только UFW INPUT или текущего container
`--dport` не является достаточной.
Перед первым `up` и после смены любого image digest обязательны permission
gates:
1. проверить LF/shebang и root ownership release-артефактов;
2. проверить manifest modes и executable bit scripts/preflight/hooks; blanket
`F644` для всего release запрещён;
3. сверить UID/GID контейнеров с owner/mode host bind mounts, secret files и
TLS staging;
4. проверить доступ целевого UID и отказ постороннему UID, включая traverse
parent directories;
5. выполнить/проверить one-shot ownership init для non-root named volumes;
6. выполнить PEM parse/key-match и Compose render;
7. запустить image-native config test/entrypoint/healthcheck с production hardening и
временными test-only upstream values;
8. выдать migration-role только необходимые временные cross-schema grants,
выполнить Alembic и отозвать grants владельцем;
9. после readiness upstream повторить edge config test/reload с production
Docker DNS names;
10. после старта проверить отсутствие permission/restart loops, реальные
healthchecks и freshness updater data.
Неуспех gate исправляется в ownership, mount/entrypoint contract или DB grants.
Временное ослабление `read_only`, запуск root, broad chmod, добавление в
`docker` group и расширение DB privileges запрещены.
## Production-замечания
### Frontend (Expo web)
| Режим | Поведение nginx |
|---|---|
| production-like / production | Статика Expo web (`expo export` / EAS web build), `FRONTEND_DEV_PROXY_ENABLED=false` |
| local dev | Опционально proxy на Expo dev server, `FRONTEND_DEV_PROXY_ENABLED=true` |
Переменные — [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), блок «Frontend (nginx)».
Два root Compose projects — production-контур первого этапа. Позже при росте можно отдельно решить:
- вынос Redis в managed cache;
- перенос `bitrix-sync` на ВМ3;
- горизонтальное масштабирование Safety API/worker/scan lanes;
- managed internal load balancer/mTLS;
- HA ВМ2.
Secret manager не является будущей опцией: для VM с утверждённым egress действует `han-secrets` + Selectel Secrets Manager, а для private/no-egress VM — контролируемая доставка root-owned secret files без сетевого secret-agent. Модель зафиксирована в arch-04 и arch-06.
### Backup, restore и cleanup
- Managed PostgreSQL должен иметь ежедневные backups и PITR; целевые RPO/RTO для MVP фиксируются в ops runbook до production-запуска.
- S3-data (`attachments`, `documents`) хранит production-файлы; удаление выполняется только через lifecycle, retention или явный audit-backed процесс.
- S3-quarantine очищается периодическим cleanup job: удаляются просроченные объекты без активного `MessageAttachment`/`safety_tasks` или объекты с завершённым deny/failed lifecycle.
- Redis не является единственным хранилищем бизнес-событий; потеря Redis не должна терять сообщения, sync tasks или audit.