Внедрение KESL на ВМ2 + замена CLAMAV на KESL

This commit is contained in:
mi
2026-09-08 01:39:37 +03:00
parent 85df788f2d
commit fdfdeaffb4
43 changed files with 2210 additions and 329 deletions
@@ -960,7 +960,7 @@ Inbound operator file:
- validate count/size/MIME and URL scheme/host policy;
- protect against SSRF: no redirects to private/link-local ranges, DNS rebinding checks, max bytes streaming;
- download with timeout to temporary stream, never local persistent disk;
- Message Safety/ClamAV не вызываются; остаточный malware-риск доверенного Bitrix24-channel принят для MVP;
- Message Safety/KESL не вызываются; остаточный malware-риск доверенного Bitrix24-channel принят для MVP;
- upload directly to S3-data attachments;
- only then atomically save attachment/message and ack inbox.
@@ -8,7 +8,7 @@
Документ задаёт, **что агент ВМ1 реализует в Compose, коде, тестах и алертах этой машины**.
ВМ1 владеет публичным edge, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контуром, Redis DB0/DB1 и локальным Collector. Message Safety, `bitrix-sync`, ClamAV и Redis Safety живут на ВМ2; ВМ1 только вызывает Safety по private HTTPS и продолжает trace.
ВМ1 владеет публичным edge, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контуром, Redis DB0/DB1 и локальным Collector. Message Safety, `bitrix-sync` и Redis Safety живут на ВМ2; KESL 12.4 и его локальный scan-broker работают на host ВМ2. ВМ1 только вызывает Safety по private HTTPS и продолжает trace.
Агент ВМ1 не добавляет scrape, дашборды и алерты сервисов ВМ2.
+1 -3
View File
@@ -7,7 +7,6 @@ MESSAGE_SAFETY_IMAGE=<registry>/han-message-safety@sha256:<digest>
BITRIX_SYNC_IMAGE=<registry>/han-bitrix-sync@sha256:<digest>
NGINX_IMAGE=nginxinc/nginx-unprivileged@sha256:<reviewed-digest>
REDIS_IMAGE=redis@sha256:<reviewed-digest>
CLAMAV_IMAGE=clamav/clamav@sha256:<reviewed-digest>
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib@sha256:<reviewed-digest>
REDIS_EXPORTER_IMAGE=oliver006/redis_exporter@sha256:<reviewed-digest>
NGINX_EXPORTER_IMAGE=nginx/nginx-prometheus-exporter@sha256:<reviewed-digest>
@@ -20,8 +19,7 @@ MESSAGE_SAFETY_HOST=0.0.0.0
MESSAGE_SAFETY_PORT=8080
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
MESSAGE_SAFETY_DNS_RESOLVERS=<vpc-resolver-ip>
MESSAGE_SAFETY_CLAMAV_HOST=clamd
MESSAGE_SAFETY_CLAMAV_PORT=3310
MESSAGE_SAFETY_ANTIVIRUS_SOCKET=/run/han-kesl/scan.sock
MESSAGE_SAFETY_ARTIFACTS_DIR=/app/app/artifacts
MESSAGE_SAFETY_MODE_FILE=/etc/han-chat/message-safety-mode.env
@@ -46,7 +46,9 @@ The full step-by-step procedure with gates and copy-paste commands is in
role. Migration credentials are mounted only into the `ops` profile jobs.
9. The setup script leaves UFW egress open for bootstrap. Before production,
constrain egress through Selectel SG/NAT/proxy to the approved PostgreSQL,
S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz and ClamAV destinations.
S3, Secrets Manager, Bitrix24, DNS/NTP and SigNoz destinations. Host KESL
receives only update-source egress approved by
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
Registry/package access exists only during controlled maintenance windows.
## Who runs what
@@ -78,8 +80,9 @@ Compose first:
`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, Redis exporter and nginx exporter).
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis,
otel-collector, Redis exporter and nginx exporter). `clamd`/`freshclam` are
absent from Compose; KESL 12.4 standalone and its broker run on the host.
4. **Selectel Secrets Manager** — populate all remote names from
`deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys,
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM for `8443`).
@@ -157,6 +160,13 @@ Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for
Execute gates **in order** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Do not enable
`han-processing.service` until Gate 5 completes successfully.
Before Message Safety starts in Gate 5, complete the operator KESL runbook,
then enable/start `han-kesl-scan-broker.socket` and verify KESL/socket status
plus `root:han-message-safety 0660` on `/run/han-kesl/scan.sock`. Only then
start Message Safety. The broker is a custom integration: exact output/exit
semantics of `kesl-control --scan-file --action Inform`, cleanup and throughput
must pass on the target VM2.
| Gate | Purpose |
| --- | --- |
| 1 | Secrets materialized via `han-secrets-vm2.service` |
@@ -195,8 +205,9 @@ Safety cutover on VM1 or Bitrix sync enablement.
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).
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, KESL
version/database date, hourly update, broker/socket status, 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
@@ -209,8 +220,8 @@ Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §7.
## Failure policy
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
- Stale/unavailable ClamAV signatures disable file capability only; they never
convert a scan error to allow.
- A stale/unavailable KESL database or broker error disables file capability
only; scan errors remain retryable and eventually return `503`, never allow.
- Redis loss may remove acceleration but PostgreSQL remains authoritative.
- OTEL outage queues within the bounded volume and must not change verdicts.
- Rollback does not downgrade schemas, delete durable tasks/mappings, or run
@@ -234,10 +245,12 @@ health, and restores the previous mode on failure. MOCK has no timeout: keep a
high-severity alert active until explicit `standard`, then verify normal
text/link/file capabilities and an EICAR canary.
## Known image exceptions
## Host KESL and broker
ClamAV images may require UID/path adjustments after validating the exact
digest. Do not weaken `read_only`, capabilities or mounts globally: document
the smallest writable signature/runtime paths and compensate with network and
resource limits. `freshclam` alone receives signature-CDN egress; `clamd`
receives none.
KESL 12.4 standalone and the root-owned fail-closed broker are not Compose
images. The Message Safety worker receives only
`/run/han-kesl/scan.sock`, not `kesl-control`, the Docker socket or host-root
access. Runtime records `scanner_engine=kesl`; `signatures_version` is the hash
of KESL version plus database date. Installation, hourly database updates,
socket permissions, clean/EICAR/error/stale gates and rollback follow
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
@@ -51,8 +51,9 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
`ops`.
9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До
production ограничьте egress правилами Selectel SG/NAT/proxy до
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz и
источников ClamAV. Registry/package repositories оставляйте только на
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP и SigNoz.
Для host KESL разрешите только источники обновления из
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md). Registry/package repositories оставляйте только на
controlled maintenance window.
## Кто что выполняет
@@ -87,7 +88,8 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
[`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, Redis exporter, nginx exporter).
otel-collector, Redis exporter, nginx exporter). `clamd`/`freshclam` в
Compose отсутствуют: KESL 12.4 standalone и broker устанавливаются на host.
4. **Selectel Secrets Manager** — заполнить все remote names из
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM для `8443`).
@@ -526,7 +528,7 @@ REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
активировать старую версию нельзя. Alembic downgrade запрещён.
При обновлении ClamAV policy образ Message Safety должен содержать согласованные
При обновлении KESL policy образ Message Safety должен содержать согласованные
seed и schema: seed `max_signature_age_hours=240`, schema maximum `720`
(30 дней). После обновления immutable image digest создайте новую config
version; существующую active version не редактируйте и не активируйте повторно:
@@ -548,7 +550,7 @@ NEXT_VERSION='<СЛЕДУЮЩИЙ_МОНОТОННЫЙ_НОМЕР>'
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose ps \
message-safety-api message-safety-worker clamd freshclam
message-safety-api message-safety-worker
unset NEXT_VERSION
```
@@ -588,8 +590,15 @@ open-file limit быть не должно. Ошибка отсутствующ
Под `root` на VM2:
```sh
cd /opt/han-chat/services
# Сначала полностью выполните operator runbook:
# deployment/kesl/RUNBOOK.KESL.ru.md
systemctl enable --now han-kesl-scan-broker.socket
systemctl --no-pager status kesl han-kesl-scan-broker.socket
test -S /run/han-kesl/scan.sock
stat -c '%U:%G %a %n' /run/han-kesl/scan.sock
/usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector
/usr/local/sbin/han-vm2-compose up -d freshclam clamd
/usr/local/sbin/han-vm2-compose up -d \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose up -d \
@@ -598,6 +607,13 @@ open-file limit быть не должно. Ошибка отсутствующ
/usr/local/sbin/han-vm2-compose ps
```
До запуска Message Safety operator KESL runbook обязан подтвердить KESL 12.4
standalone, успешное ежечасное обновление, допустимый database date и canary.
Socket должен иметь `root:han-message-safety 0660` и не быть доступен посторонним
UID. Broker является custom
integration: неизвестный output/exit code `kesl-control --scan-file --action Inform`
считается scanner error, а не clean. Формат и throughput проверяются на target VM2.
`otel-collector` автоматически запускает одноразовый `otel-queue-init`. Он
выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`;
сам Collector стартует только после этого.
@@ -892,7 +908,7 @@ journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service
- container unhealthy/restart/OOM;
- срок TLS;
- возраст ClamAV signatures;
- KESL version/database date, результат ежечасного update и broker/socket status;
- OTEL queue/export errors;
- disk/RAM;
- активный MOCK mode;
@@ -1107,8 +1123,9 @@ plan.
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
контент.
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
capability; ошибка сканирования никогда не превращается в allow.
- Устаревшая/недоступная база KESL или ошибка broker отключают только файловую
capability; ошибка сканирования никогда не превращается в allow и ведёт к
retry/`503`.
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
истины.
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
@@ -1134,10 +1151,12 @@ han-message-safety-mode mock --text-free false --file-free false
таймаута: держите high-severity alert активным до явного `standard`, затем
проверьте нормальные text/link/file capabilities и EICAR-canary.
## Известные исключения по образам
## Host KESL и broker
Образы ClamAV могут потребовать корректировок UID/path после валидации
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
задокументируйте минимальные writable пути для сигнатур/runtime и
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
получает только `freshclam`; `clamd` — нет.
KESL 12.4 standalone и root-owned fail-closed broker не являются Compose
образами. Message Safety worker получает только Unix socket
`/run/han-kesl/scan.sock`; доступ к `kesl-control`, Docker socket и host root
ему не выдаётся. `scanner_engine=kesl`, а `signatures_version` вычисляется как
hash KESL version + database date. Установка, ежечасное обновление, права
socket, clean/EICAR/error/stale gates и rollback выполняются строго по
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
@@ -1,7 +1,7 @@
[Unit]
Description=HAN Processing VM2 root Compose stack
Requires=docker.service han-secrets-vm2.service
After=docker.service han-secrets-vm2.service network-online.target
Requires=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket
After=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket network-online.target
[Service]
Type=oneshot
@@ -0,0 +1,95 @@
# Карта доказательств KESL на ВМ2
Заполняется оператором после `RUNBOOK.KESL.ru.md`. Не включать activation code,
secrets, ПД, S3 object key, file bytes, полный checksum или EICAR.
## 1. Изменение
- Change ID / окно:
- Оператор / approvers Security, Service, Operations:
- Hostname, Ubuntu, kernel, architecture:
- KESL package/version, SHA-256, источник:
- HAN release SHA и image digests:
- Коммерческая сборка не заявлена сертифицированной ФСТЭК: да / нет
- KSN decision и правовое основание:
## 2. Baseline и stop conditions
- VM2 containers healthy/running:
- Public/private smoke:
- CPU, available RAM, swap activity, disk/IO wait:
- Safety p95/p99, error rate, queue age:
- Redis latency/blocked clients:
- Restart/OOM, OTEL queue:
- Утверждённые пороги и rollback approver:
## 3. АВЗ.1
Норма: Приказ ФСТЭК №21, приложение АВЗ.1; п. 8.6 — обнаружение
вредоносных программ/информации и реагирование.
- [ ] `kesl.service` active, лицензия действительна.
- [ ] File Threat Protection task 1 = `Started`.
- [ ] `ActionOnThreat=DisinfectDeleteIfNotPossible`, `ScanArchived=No`.
- [ ] fanotify on-access обнаружил и обработал разрешённый EICAR.
- [ ] Событие detection/action присутствует в KESL events.
- [ ] Исключения ограничены фактическими hot-data mountpoint.
- [ ] Broker staging отсутствует в `ExcludedFromScanScope`.
- [ ] Broker подтверждает `scanned >= 1`, `skipped = 0`, `errors = 0`.
- [ ] Firewall, smoke и health после Block успешны.
- [ ] 24 часа без неприемлемой деградации/OOM/restart/5xx.
Артефакты/время/результат:
## 4. АВЗ.2
Норма: Приказ ФСТЭК №21, приложение АВЗ.2 — обновление базы признаков
вредоносных компьютерных программ.
- [ ] Update task 6 вручную завершилась успешно.
- [ ] Базы загружены, дата актуальна.
- [ ] Schedule = Hourly.
- [ ] Наблюдён последующий автоматический successful update.
- [ ] Alert на update failure / unloaded / age >240h / license failure.
- [ ] Назначен ежедневный контроль и owner.
Артефакты/время последнего automatic update:
## 5. Message Safety scan-broker
- [ ] Socket `root:han-message-safety:0660`, TCP listener отсутствует.
- [ ] Clean corpus → `clean`; Message Safety final `200 allow`.
- [ ] EICAR → `infected`; Message Safety sticky `403 deny`.
- [ ] KESL stopped/timeout/unknown output → retry/terminal `503`, не allow/deny.
- [ ] `scanner_engine=kesl`.
- [ ] `signatures_version` меняется при обновлении KESL databases.
- [ ] Старый cache не используется после смены signatures version.
- [ ] Logs/traces не содержат bytes, object key, checksum, filename или secrets.
- [ ] 5 slots / 2 files per second gate пройден.
Результаты corpus/load и ссылки на безопасные метрики:
## 6. РСБ и АНЗ.2
- [ ] Определены и защищены KESL detection/remediation/update/license events.
- [ ] Определены место/срок хранения и экспорт/регламент просмотра.
- [ ] Версия KESL и upgrade lifecycle контролируются.
- [ ] Kernel/Docker/KESL upgrade требует compatibility pilot + evidence delta.
## 7. Cutover и rollback
- [ ] До cutover KESL+ClamAV coexistence не нарушило resource gates.
- [ ] После cutover `clamd`/`freshclam` отсутствуют, старый egress закрыт.
- [ ] Предыдущая совместимая release/config пара зафиксирована.
- [ ] Desk check: application rollback возвращает ClamAV release.
- [ ] Desk check: минимальный host rollback — `kesl-control --stop-task 1`.
- [ ] Rollback не удаляет Docker volumes и не использует `down -v`.
## 8. Итог
- АВЗ.1: принято / не принято; ограничения:
- АВЗ.2: принято / не принято; ограничения:
- Message Safety KESL cutover: принято / не принято:
- Residual risk custom broker / parser / throughput:
- Operations / Security / Service owner, ФИО, подпись, дата:
- Следующий review:
@@ -0,0 +1,288 @@
# KESL 12.4 standalone на ВМ2 Processing
Операторский runbook для Ubuntu 24.04 ВМ2. Команды выполняет персональная роль
`admin` через `sudo` в отдельное maintenance window. Репозиторий не устанавливает,
не активирует и не запускает KESL автоматически.
Цели:
1. АВЗ.1: обнаружение и реагирование через host File Threat Protection.
2. АВЗ.2: автоматическое обновление баз.
3. Замена ClamAV в Message Safety локальным fail-closed scan-broker.
KESL 12.4 commercial не заявляется как сертифицированная ФСТЭК сборка.
## 0. Approvals и stop conditions
До окна зафиксировать change ID, оператора, Security/Service/Operations approvers,
hostname, ОС/ядро, package version/hash/source, лицензию, release SHA и rollback.
EULA, Privacy Policy и KSN согласовать отдельно. Activation code, ключи и secrets
не помещать в репозиторий, историю shell, chat или evidence.
Остановиться, если:
- ОС/архитектура/ядро отсутствуют в матрице KESL 12.4;
- установлен другой AV или неизвестный KESL;
- свободно менее 10 ГБ, нет 4 ГБ swap либо baseline ВМ2 уже деградирован;
- KESL+ClamAV coexistence оставляет менее 2 ГБ available memory, вызывает
устойчивый swap IO, OOM/restart, IO wait >10% за 5 минут;
- p95 Safety/Redis вырос более чем на 20%, queue age >30 секунд;
- package hash/source не подтверждены, лицензия недействительна или базы не загружены.
Не совмещать с deploy, миграциями, backup, TLS renewal, Docker restart или reboot.
## 1. Read-only baseline
```sh
date -Is
hostnamectl
uname -a
dpkg --print-architecture
free -h
swapon --show
df -hT / /var/lib/docker /tmp
df -ih / /var/lib/docker /tmp
systemctl is-active docker fail2ban ufw han-processing.service
dpkg-query -W -f='${Package}\t${Version}\t${Status}\n' \
kesl kesl-gui kav4fs 2>/dev/null || true
/usr/local/sbin/han-vm2-compose ps
docker stats --no-stream
iptables -S HAN-CHAT-VM2
ufw status verbose
```
Сохранить baseline CPU/RAM/swap/IO, Safety latency/error/queue, Redis latency,
container restart/OOM, OTEL queue и внешний/private smoke. Получить фактические
mountpoint только для `redis-safety-data` и `otel-queue` через
`docker volume inspect`; prefix Compose не угадывать.
## 2. Дистрибутив и установка
Использовать amd64 DEB KESL 12.4 из официального канала. Хеши пилота ВМ1 не
считать универсальными: записать фактические SHA-256 DEB/ISO и, если поставщик
публикует ФИКС/ГОСТ, проверить соответствующим алгоритмом.
Проверка файла:
```
apt install rhash
ISO='/var/lib/han-deploy/incoming/049-16-d-01.iso'
command -v rhash
rhash --gost "$ISO"
```
Должно быть 6a94b16afad211e8b9be5ec86f5379184f2b3a9e5869843fe763e2796e5ac1d3
Если совпадёт, извлеките DEB:
```
install -d -m 0700 -o root -g root \
/root/kesl-install /mnt/kesl-iso
mount -o ro,loop "$ISO" /mnt/kesl-iso
find /mnt/kesl-iso -type f \
\( -iname 'kesl_*_amd64.deb' -o -iname '*.deb' \) |
sort
```
Затем:
```
DEB='/mnt/kesl-iso/kesl/kesl_12.4.0-1225_amd64.deb'
dpkg-deb -f "$DEB" Package Version Architecture
sha256sum "$DEB"
install -m 0600 -o root -g root \
"$DEB" /root/kesl-install/kesl.deb
umount /mnt/kesl-iso
rmdir /mnt/kesl-iso
```
Ожидаемые свойства: kesl, версия 12.4.x, архитектура amd64. После этого можно выполнять:
```
apt-get install /root/kesl-install/kesl.deb
```
Создать `/root/kesl-install/autoinstall.ini` mode `0600`:
```ini
KSVLA_MODE=No
ENDPOINT_AGENT_MODE=No
EULA_AGREED=<Yes_AFTER_APPROVAL>
PRIVACY_POLICY_AGREED=<Yes_AFTER_APPROVAL>
USE_KSN=<Yes_OR_No_AFTER_APPROVAL>
GROUP_CLEAN=Yes
LOCALE=ru_RU.UTF-8
INSTALL_LICENSE=None
UPDATER_SOURCE=KLServers
UPDATE_EXECUTE=No
KERNEL_SRCS_INSTALL=No
USE_GUI=No
CONFIGURE_SELINUX=No
DISABLE_PROTECTION=Yes
INTERCEPTOR_MODE=UseFanotify
ENABLE_TRACES_ON_FIRST_STARTUP=No
```
```sh
chmod 0600 /root/kesl-install/autoinstall.ini
/opt/kaspersky/kesl/bin/kesl-setup.pl \
--autoinstall=/root/kesl-install/autoinstall.ini
systemctl --no-pager status kesl
kesl-control --app-info --json
kesl-control --get-task-list
```
При `INSTALL_LICENSE=None` setup может вернуть 71; не запускать повторно, если
`kesl.service` active и компоненты защиты остановлены. Активацию выполнить без
history, затем удалить переменную:
```sh
set +o history
unset HISTFILE
read -rsp 'KESL activation code: ' KESL_CODE; echo
kesl-control --add-active-key "$KESL_CODE"
unset KESL_CODE
set -o history
kesl-control -L --query
```
## 3. Resource budget до первой проверки
Defaults `ScanMemoryLimit=8192` и `MaxMemory=auto` не принимать на ВМ2 8 ГБ.
Стартовый кандидат — `ScanMemoryLimit=1024`, `MaxMemory=2048MB`; окончательные
значения определяются baseline и coexistence с ещё работающим ClamAV.
```sh
kesl-control --get-app-settings \
--file /root/kesl-install/app-settings.before.ini
kesl-control --set-app-settings \
UseOnDemandCPULimit=Yes OnDemandCPULimit=<APPROVED_VALUE>
```
`ScanMemoryLimit`/`MaxMemory` менять только по официальной процедуре в секции
`[General]` `/var/opt/kaspersky/kesl/common/kesl.ini` при остановленном KESL.
## 4. АВЗ.2 — базы
ID подтвердить через `--get-task-list`.
```sh
kesl-control --get-settings 6
kesl-control --start-task 6 -W
kesl-control --get-task-state 6
kesl-control --app-info --json
START="$(LC_ALL=C date +'%Y/%b/%d %H:%M:%S;1')"
kesl-control --set-schedule 6 RuleType=Hourly \
"StartTime=${START}" RunMissedStartRules=No RandomInterval=0
kesl-control --get-schedule 6
```
До приёмки наблюдать хотя бы один автоматический hourly cycle. Alert: ошибка
Update, базы старше 240 часов, базы не загружены, лицензия недействительна.
## 5. Host protection — АВЗ.1
Добавить только фактические hot-data mountpoint Redis Safety и OTEL queue.
Не исключать `/var/lib/docker`, release, incoming, secrets, `/tmp` или `/`.
Не исключать staging broker `/var/lib/han-kesl-scan/staging`: исключение task 1
распространяется и на явный `Scan_File`, поэтому файл будет пропущен. Если
исключение уже добавлено, удалить его до проверки broker:
```sh
kesl-control --set-settings 1 \
--del-exclusion /var/lib/han-kesl-scan/staging
```
```sh
kesl-control --get-settings 1 \
--file /root/kesl-install/file-threat.before.ini
kesl-control --set-settings 1 \
--add-exclusion <REDIS_SAFETY_DATA_MOUNTPOINT>
kesl-control --set-settings 1 \
--add-exclusion <OTEL_QUEUE_MOUNTPOINT>
kesl-control --start-task 1
kesl-control --set-settings 1 \
ActionOnThreat=DisinfectDeleteIfNotPossible ScanArchived=No
kesl-control --get-task-state 1
kesl-control --get-settings 1
```
Проверять события, health, firewall и ресурсы каждые 15 минут. При деградации:
`kesl-control --stop-task 1`.
## 6. Scan-broker
После активации release повторить host setup для установки root-owned unit,
затем проверить файлы и включить socket:
```sh
systemd-analyze verify \
/etc/systemd/system/han-kesl-scan-broker.service \
/etc/systemd/system/han-kesl-scan-broker.socket
systemctl enable --now han-kesl-scan-broker.socket
systemctl is-active kesl han-kesl-scan-broker.socket
stat -c '%U:%G:%a %n' /run/han-kesl/scan.sock
/usr/local/libexec/han-kesl-scan-broker --probe
journalctl --since '-10 min' --no-pager -u han-kesl-scan-broker.service
```
Ожидается `root:han-message-safety:660`. Broker не имеет TCP port, принимает
не более 5 MiB, не получает S3 key/имя/ПД, запускает фиксированный Scan_File
`Inform` и возвращает только verdict/version. Неизвестный вывод KESL — error.
Для `Scan_File` код возврата `72` означает обнаружение угрозы и принимается
только вместе с итогом `detected > 0`; любое противоречие обрабатывается как
`scanner_unavailable`.
Broker service намеренно не содержит `Requires=kesl.service`: иначе обращение
к socket автоматически запустит остановленный KESL и сделает fail-closed
проверку недостоверной. Порядок запуска обеспечивается `After=kesl.service`.
## 7. Приёмка и cutover
EICAR разрешён только письменно Security и не хранится в repository/evidence.
Отдельно выполнить:
1. host on-access: обнаружение и реагирование task 1;
2. broker: clean → clean, EICAR → infected, остановленный KESL → error;
3. 5 concurrent broker slots и corpus 2 file/s; p95 final ≤60 s, public wait
≤300 s, queue age p95 ≤5 s;
4. Message Safety: clean `200`, malware `403`, scanner error `503`, verdict sticky;
5. smoke ВМ2/ВМ1, firewall unchanged, 24 часа без OOM/restart/5xx/degradation.
Только после gates активировать новую config version, release без
`clamd`/`freshclam`, затем подтвердить отсутствие их контейнеров/volumes и
закрыть старый signature-CDN egress.
## 8. Evidence
Заполнить `EVIDENCE.AVZ.ru.md` без activation code, secrets, file bytes, S3 key,
полного checksum и EICAR. Сохранить безопасные excerpts:
```sh
systemctl is-active kesl han-kesl-scan-broker.socket
kesl-control --app-info --json
kesl-control --get-task-state 1
kesl-control --get-settings 1
kesl-control --get-schedule 6
kesl-control -E --query -n 100 --reverse --json
/usr/local/sbin/han-vm2-compose ps
```
## 9. Rollback
Application rollback: вернуть предыдущие совместимые image/config/release с
`clamd`, не создавать новый task id для уже pending request. Host KESL оставить,
если он не причина инцидента.
Если проблема в host protection:
```sh
kesl-control --stop-task 1
# если KESL не отвечает:
systemctl stop kesl
```
`apt-get purge kesl` — только по решению approver. Не выполнять compose
`down -v`, не удалять Redis/OTEL volumes и не менять firewall ради обхода.
После rollback повторить smoke, resource checks и зафиксировать incident.
@@ -0,0 +1,43 @@
[Unit]
Description=HAN local KESL scan broker
Requires=han-kesl-scan-broker.socket
After=kesl.service
[Service]
Type=simple
User=root
Group=han-message-safety
ExecStart=/usr/local/libexec/han-kesl-scan-broker
Environment=HAN_KESL_SCAN_TIMEOUT_SEC=60
Environment=HAN_KESL_SCAN_CONCURRENCY=5
StateDirectory=han-kesl-scan
StateDirectoryMode=0700
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
ReadWritePaths=/var/lib/han-kesl-scan
CapabilityBoundingSet=
AmbientCapabilities=
LimitCORE=0
LimitNOFILE=128
TasksMax=64
MemoryMax=256M
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,15 @@
[Unit]
Description=HAN KESL scan broker socket
Before=han-processing.service
[Socket]
ListenStream=/run/han-kesl/scan.sock
SocketUser=root
SocketGroup=han-message-safety
SocketMode=0660
DirectoryMode=0750
RemoveOnStop=true
Service=han-kesl-scan-broker.service
[Install]
WantedBy=sockets.target
@@ -0,0 +1 @@
d /run/han-kesl 0750 root han-message-safety -
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""Local, fail-closed bridge between Message Safety and host KESL."""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import re
import socket
import struct
import sys
import tempfile
from pathlib import Path
from typing import Any
MAX_HEADER_BYTES = 4096
MAX_FILE_BYTES = 5 * 1024 * 1024
MAX_RESPONSE_BYTES = 16 * 1024
DEFAULT_TIMEOUT_SECONDS = 60.0
KESL_CONTROL = Path("/opt/kaspersky/kesl/bin/kesl-control")
STAGING_DIR = Path("/var/lib/han-kesl-scan/staging")
_SUMMARY_PATTERNS = {
"scanned": re.compile(
r"(?im)^\s*(?:Scanned objects|Objects scanned|Проверенные объекты)"
r"\s*:\s*(\d+)\s*$"
),
"detected": re.compile(
r"(?im)^\s*(?:Total detected objects|Всего обнаружено объектов)"
r"\s*:\s*(\d+)\s*$"
),
"errors": re.compile(
r"(?im)^\s*(?:Scan errors|Ошибки проверки)\s*:\s*(\d+)\s*$"
),
"skipped": re.compile(
r"(?im)^\s*(?:Skipped objects|Objects skipped|Пропущено объектов)"
r"\s*:\s*(\d+)\s*$"
),
}
_THREAT_RE = re.compile(r"(?im)^\s*(?:Threat|Detect name)\s*:\s*(.{1,256})\s*$")
class ProtocolError(ValueError):
pass
class KeslError(RuntimeError):
pass
def _json_bytes(payload: dict[str, Any]) -> bytes:
body = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode()
if len(body) > MAX_RESPONSE_BYTES:
raise KeslError("response exceeds protocol limit")
return struct.pack(">I", len(body)) + body
async def _read_frame(reader: asyncio.StreamReader) -> tuple[dict[str, Any], bytes]:
(header_size,) = struct.unpack(">I", await reader.readexactly(4))
if header_size < 2 or header_size > MAX_HEADER_BYTES:
raise ProtocolError("invalid header size")
try:
header = json.loads((await reader.readexactly(header_size)).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ProtocolError("invalid header") from exc
if not isinstance(header, dict) or set(header) - {"op", "size"}:
raise ProtocolError("invalid header fields")
operation = header.get("op")
if operation == "status":
if "size" in header:
raise ProtocolError("status request cannot contain a body")
return header, b""
if operation != "scan" or not isinstance(header.get("size"), int):
raise ProtocolError("unsupported operation")
size = header["size"]
if isinstance(size, bool) or size < 1 or size > MAX_FILE_BYTES:
raise ProtocolError("invalid file size")
return header, await reader.readexactly(size)
def parse_scan_output(output: str, *, exit_code: int = 0) -> tuple[str, str | None]:
"""Parse the documented KESL Scan_File summary, rejecting format drift."""
if exit_code not in (0, 72):
raise KeslError(f"unexpected KESL scan exit code: {exit_code}")
summary: dict[str, int] = {}
for name, pattern in _SUMMARY_PATTERNS.items():
match = pattern.search(output)
if not match:
raise KeslError(f"KESL scan summary field is missing: {name}")
summary[name] = int(match.group(1))
if summary["scanned"] < 1:
raise KeslError("KESL did not scan the submitted object")
if summary["errors"] or summary["skipped"]:
raise KeslError("KESL scan completed with errors or skipped objects")
detected = summary["detected"]
if (exit_code == 72) != (detected > 0):
raise KeslError("KESL exit code contradicts scan summary")
if detected == 0:
return "clean", None
threat = _THREAT_RE.search(output)
return "infected", threat.group(1).strip() if threat else "detected"
def _flatten_json(value: Any, prefix: str = "") -> dict[str, Any]:
result: dict[str, Any] = {}
if isinstance(value, dict):
for key, item in value.items():
normalized = re.sub(r"[^\w]+", "_", str(key).casefold()).strip("_")
result.update(_flatten_json(item, f"{prefix}_{normalized}".strip("_")))
else:
result[prefix] = value
return result
def parse_app_info(output: str) -> dict[str, Any]:
try:
flattened = _flatten_json(json.loads(output))
except json.JSONDecodeError as exc:
raise KeslError("invalid KESL app-info JSON") from exc
def find(*suffixes: str) -> Any:
for suffix in suffixes:
for key, value in flattened.items():
if key == suffix or key.endswith(f"_{suffix}"):
return value
raise KeslError(f"KESL app-info field is missing: {suffixes[0]}")
version = str(find("version", "application_version", "версия")).strip()
databases_loaded = find(
"databases_loaded",
"application_databases_loaded",
"базы_приложения_загружены",
)
databases_date = str(
find(
"databases_date",
"last_release_date_of_databases",
"database_date",
"дата_последнего_выпуска_баз_приложения",
)
).strip()
license_info = str(
find(
"license_info",
"license_status",
"key_status",
"license_information",
"информация_о_лицензии_приложения",
)
).strip()
if databases_loaded not in (True, "Yes", "yes", "true", "Да", "да", 1):
raise KeslError("KESL databases are not loaded")
if not version or not databases_date or databases_date.upper() == "N/A":
raise KeslError("KESL version or database date is unavailable")
if "valid" not in license_info.lower() and "действ" not in license_info.lower():
raise KeslError("KESL license is not valid")
signature_source = f"{version}\0{databases_date}".encode()
return {
"status": "ready",
"engine_version": version,
"databases_date": databases_date,
"signatures_version": "sha256:" + hashlib.sha256(signature_source).hexdigest(),
}
async def _run_kesl(
*arguments: str,
timeout: float,
allowed_returncodes: frozenset[int] = frozenset({0}),
) -> tuple[str, int]:
if not KESL_CONTROL.is_file():
raise KeslError("KESL control utility is unavailable")
process = await asyncio.create_subprocess_exec(
str(KESL_CONTROL),
*arguments,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, "LC_ALL": "C", "LANG": "C", "LANGUAGE": "C"},
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
except TimeoutError:
process.kill()
await process.wait()
raise KeslError("KESL command timed out") from None
if process.returncode not in allowed_returncodes:
detail = stderr.decode("utf-8", "replace").strip()[:256]
raise KeslError(f"KESL command failed: {detail or process.returncode}")
return stdout.decode("utf-8", "replace"), process.returncode
class Broker:
def __init__(self, *, timeout: float, concurrency: int) -> None:
self.timeout = timeout
self.slots = asyncio.Semaphore(concurrency)
async def status(self) -> dict[str, Any]:
output, _ = await _run_kesl(
"--app-info", "--json", timeout=min(self.timeout, 10.0)
)
return parse_app_info(output)
async def scan(self, body: bytes) -> dict[str, Any]:
async with self.slots:
status_before = await self.status()
STAGING_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
descriptor, name = tempfile.mkstemp(prefix="scan-", dir=STAGING_DIR)
path = Path(name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb", closefd=True) as target:
target.write(body)
target.flush()
os.fsync(target.fileno())
output, exit_code = await _run_kesl(
"--scan-file",
str(path),
"--action",
"Inform",
timeout=self.timeout,
allowed_returncodes=frozenset({0, 72}),
)
verdict, threat = parse_scan_output(output, exit_code=exit_code)
status_after = await self.status()
if (
status_before["signatures_version"]
!= status_after["signatures_version"]
):
raise KeslError("KESL databases changed during scan")
return {
"status": "completed",
"verdict": verdict,
"threat": threat,
"engine_version": status_after["engine_version"],
"signatures_version": status_after["signatures_version"],
}
finally:
path.unlink(missing_ok=True)
async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
header, body = await asyncio.wait_for(_read_frame(reader), 10.0)
result = await (self.status() if header["op"] == "status" else self.scan(body))
except (ProtocolError, asyncio.IncompleteReadError, TimeoutError):
result = {"status": "error", "error": "invalid_request"}
except KeslError as exc:
print(f"KESL scanner unavailable: {exc}", file=sys.stderr, flush=True)
result = {"status": "error", "error": "scanner_unavailable"}
except Exception:
result = {"status": "error", "error": "internal_error"}
writer.write(_json_bytes(result))
await writer.drain()
writer.close()
await writer.wait_closed()
def _activation_socket() -> socket.socket:
if int(os.environ.get("LISTEN_FDS", "0")) != 1 or os.getpid() != int(
os.environ.get("LISTEN_PID", "0")
):
raise SystemExit("exactly one systemd activation socket is required")
descriptor = socket.fromfd(3, socket.AF_UNIX, socket.SOCK_STREAM)
descriptor.setblocking(False)
return descriptor
async def serve() -> None:
timeout = float(os.environ.get("HAN_KESL_SCAN_TIMEOUT_SEC", DEFAULT_TIMEOUT_SECONDS))
concurrency = int(os.environ.get("HAN_KESL_SCAN_CONCURRENCY", "5"))
if not 1 <= concurrency <= 16 or not 1 <= timeout <= 300:
raise SystemExit("invalid broker limits")
server = await asyncio.start_unix_server(
Broker(timeout=timeout, concurrency=concurrency).handle,
sock=_activation_socket(),
limit=MAX_FILE_BYTES + MAX_HEADER_BYTES + 4,
)
async with server:
await server.serve_forever()
async def probe() -> None:
reader, writer = await asyncio.wait_for(
asyncio.open_unix_connection("/run/han-kesl/scan.sock"), 5.0
)
try:
header = json.dumps({"op": "status"}, separators=(",", ":")).encode()
writer.write(struct.pack(">I", len(header)) + header)
await writer.drain()
(size,) = struct.unpack(">I", await asyncio.wait_for(reader.readexactly(4), 10.0))
if size < 2 or size > MAX_RESPONSE_BYTES:
raise SystemExit("invalid broker response")
response = json.loads((await asyncio.wait_for(reader.readexactly(size), 10.0)).decode())
if response.get("status") != "ready":
print(json.dumps(response, ensure_ascii=True, sort_keys=True), file=sys.stderr)
raise SystemExit("KESL broker is not ready")
print(json.dumps(response, ensure_ascii=True, sort_keys=True))
finally:
writer.close()
await writer.wait_closed()
if __name__ == "__main__":
if not sys.argv[1:]:
asyncio.run(serve())
elif sys.argv[1:] == ["--probe"]:
asyncio.run(probe())
else:
raise SystemExit("usage: han-kesl-scan-broker [--probe]")
@@ -0,0 +1,62 @@
# Reference decisions for KESL 12.4 on HAN Processing VM2.
# Do not import this partial file into KESL. Export the installed-build
# settings, review them, and apply individual values from RUNBOOK.KESL.ru.md.
[deployment]
product_major_minor=12.4
mode=standard_standalone
gui=disabled
update_source=KLServers
interceptor=fanotify
network_features=disabled
ksn=<Yes_OR_No_AFTER_SECURITY_AND_LEGAL_APPROVAL>
[resource_budget]
# VM2 starts at 8 GiB RAM. Values require measured baseline and coexistence gate.
scan_memory_limit_mb=<1024_OR_APPROVED_VALUE>
max_memory=<2048MB_OR_APPROVED_VALUE>
use_on_demand_cpu_limit=Yes
on_demand_cpu_limit_percent=<APPROVED_VALUE>
[update_task_6]
rule_type=Hourly
required_result=completed_successfully
required_bases_loaded=Yes
stale_bases_alert_hours=240
[file_threat_protection_task_1]
steady_state=Started
action_on_threat=DisinfectDeleteIfNotPossible
scan_archived=No
[message_safety_broker]
socket=/run/han-kesl/scan.sock
max_file_bytes=5242880
scan_action=Inform
fail_closed=Yes
staging=/var/lib/han-kesl-scan/staging
# Do not add staging to ExcludedFromScanScope: KESL Scan_File can skip it.
staging_exclusion=forbidden
explicit_scan_before_allow=required
[allowed_hot_data_exclusions]
item_0000=<REDIS_SAFETY_DATA_MOUNTPOINT>
item_0001=<OTEL_QUEUE_MOUNTPOINT>
[forbidden_broad_exclusions]
item_0000=/var/lib/docker
item_0001=/var/lib/docker/overlay2
item_0002=/opt/han-chat
item_0003=/var/lib/han-deploy/incoming
item_0004=/etc/han
item_0005=/run/han-chat
item_0006=/tmp
item_0007=/
[acceptance]
host_on_access_eicar=required
broker_clean_eicar_error=required
automatic_hourly_update_observed=required
message_safety_200_403_503=required
performance_two_files_per_second=required
resource_comparison_24h=required
@@ -0,0 +1,140 @@
from __future__ import annotations
import asyncio
import importlib.util
import json
import struct
import sys
from pathlib import Path
import pytest
MODULE_PATH = Path(__file__).with_name("kesl_scan_broker.py")
SPEC = importlib.util.spec_from_file_location("kesl_scan_broker", MODULE_PATH)
assert SPEC and SPEC.loader
broker = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = broker
SPEC.loader.exec_module(broker)
def test_scan_output_is_strict_and_fail_closed() -> None:
clean = (
"Scanned objects: 1\n"
"Total detected objects: 0\n"
"Scan errors: 0\n"
"Skipped objects: 0\n"
)
assert broker.parse_scan_output(clean) == ("clean", None)
assert broker.parse_scan_output(
"Threat: EICAR-Test-File\n"
"Scanned objects: 1\n"
"Total detected objects: 1\n"
"Scan errors: 0\n"
"Skipped objects: 0\n",
exit_code=72,
) == ("infected", "EICAR-Test-File")
with pytest.raises(broker.KeslError):
broker.parse_scan_output("scan allegedly completed")
def test_scan_output_accepts_russian_summary_and_rejects_skipped_file() -> None:
clean = (
"Проверенные объекты: 1\n"
"Всего обнаружено объектов: 0\n"
"Ошибки проверки: 0\n"
"Пропущено объектов: 0\n"
)
assert broker.parse_scan_output(clean) == ("clean", None)
skipped = clean.replace("Проверенные объекты: 1", "Проверенные объекты: 0").replace(
"Пропущено объектов: 0", "Пропущено объектов: 1"
)
with pytest.raises(broker.KeslError):
broker.parse_scan_output(skipped)
def test_scan_output_rejects_exit_code_summary_mismatch() -> None:
infected = (
"Scanned objects: 1\n"
"Total detected objects: 1\n"
"Scan errors: 0\n"
"Skipped objects: 0\n"
)
clean = infected.replace("Total detected objects: 1", "Total detected objects: 0")
with pytest.raises(broker.KeslError):
broker.parse_scan_output(infected, exit_code=0)
with pytest.raises(broker.KeslError):
broker.parse_scan_output(clean, exit_code=72)
def test_app_info_builds_versioned_signature() -> None:
raw = json.dumps(
{
"product info": {
"version": "12.4.0.1225",
"databases_loaded": True,
"databases_date": "2026-09-07T11:25:00+00:00",
"license_info": "The key is valid",
}
}
)
status = broker.parse_app_info(raw)
assert status["status"] == "ready"
assert status["engine_version"] == "12.4.0.1225"
assert str(status["signatures_version"]).startswith("sha256:")
def test_app_info_accepts_kesl_12_4_control_json() -> None:
raw = json.dumps(
{
"Application databases loaded": "Yes",
"Application license information": "The key is valid",
"Last release date of databases": "2026-09-07 15:41:00",
"Version": "12.4.0.1225",
}
)
status = broker.parse_app_info(raw)
assert status["status"] == "ready"
assert status["engine_version"] == "12.4.0.1225"
assert status["databases_date"] == "2026-09-07 15:41:00"
def test_app_info_accepts_russian_kesl_12_4_control_json() -> None:
raw = json.dumps(
{
"Базы приложения загружены": "Да",
"Версия": "12.4.0.1225",
"Дата последнего выпуска баз приложения": "2026-09-07 15:41:00",
"Информация о лицензии приложения": "Ключ действителен",
}
)
status = broker.parse_app_info(raw)
assert status["status"] == "ready"
assert status["engine_version"] == "12.4.0.1225"
assert status["databases_date"] == "2026-09-07 15:41:00"
def test_app_info_rejects_license_and_database_failures() -> None:
base = {
"version": "12.4",
"databases_loaded": True,
"databases_date": "2026-09-07T11:25:00+00:00",
"license_info": "The key is valid",
}
for key, value in (("databases_loaded", False), ("license_info", "Expired")):
candidate = dict(base)
candidate[key] = value
with pytest.raises(broker.KeslError):
broker.parse_app_info(json.dumps(candidate))
def test_protocol_rejects_oversized_file() -> None:
async def exercise() -> None:
header = json.dumps({"op": "scan", "size": broker.MAX_FILE_BYTES + 1}).encode()
reader = asyncio.StreamReader()
reader.feed_data(struct.pack(">I", len(header)) + header)
reader.feed_eof()
with pytest.raises(broker.ProtocolError):
await broker._read_frame(reader)
asyncio.run(exercise())
@@ -21,6 +21,24 @@ fail() {
fail "root-owned Message Safety mode file is missing; initialize standard mode"
/usr/bin/getent group han-message-safety | /usr/bin/awk -F: '$3 == 10001 {found=1} END {exit !found}' ||
fail "han-message-safety group with GID 10001 is missing"
[ -x /opt/kaspersky/kesl/bin/kesl-control ] ||
fail "KESL 12.4 control utility is missing"
/usr/bin/systemctl is-active --quiet kesl.service ||
fail "kesl.service is not active"
/usr/bin/systemctl is-active --quiet han-kesl-scan-broker.socket ||
fail "KESL scan broker socket is not active"
[ "$(/usr/bin/stat -c '%U:%G:%a' /run/han-kesl 2>/dev/null || true)" = \
"root:han-message-safety:750" ] ||
fail "KESL broker runtime directory must be root:han-message-safety 0750"
[ -S /run/han-kesl/scan.sock ] || fail "KESL scan broker Unix socket is missing"
if [ -S /run/han-kesl/scan.sock ]; then
[ "$(/usr/bin/stat -c '%U:%G:%a' /run/han-kesl/scan.sock)" = \
"root:han-message-safety:660" ] ||
fail "KESL broker socket must be root:han-message-safety 0660"
fi
[ -x /usr/local/libexec/han-kesl-scan-broker ] &&
/usr/local/libexec/han-kesl-scan-broker --probe >/dev/null ||
fail "KESL scan broker readiness probe failed"
public_tls_dir=/var/lib/han-chat/public-tls
/usr/bin/getent group han-nginx-tls | /usr/bin/awk -F: '$3 == 11001 {found=1} END {exit !found}' ||
@@ -59,7 +77,7 @@ if [ -f "$ENV_FILE" ]; then
true|false) ;;
*) fail "OTEL_REMOTE_TLS_INSECURE must be exactly true or false" ;;
esac
for image_key in MESSAGE_SAFETY_IMAGE BITRIX_SYNC_IMAGE NGINX_IMAGE REDIS_IMAGE CLAMAV_IMAGE OTEL_COLLECTOR_IMAGE REDIS_EXPORTER_IMAGE NGINX_EXPORTER_IMAGE; do
for image_key in MESSAGE_SAFETY_IMAGE BITRIX_SYNC_IMAGE NGINX_IMAGE REDIS_IMAGE OTEL_COLLECTOR_IMAGE REDIS_EXPORTER_IMAGE NGINX_EXPORTER_IMAGE; do
image=$(/usr/bin/awk -F= -v key="$image_key" '$1 == key {print substr($0, index($0, "=") + 1)}' "$ENV_FILE")
echo "$image" | /usr/bin/grep -Eq '@sha256:[0-9a-f]{64}$' ||
fail "$image_key must be pinned by sha256 digest"
@@ -224,7 +242,11 @@ fi
for protected in \
"$ROOT/docker-compose.yml" \
"$ROOT/deployment/han-message-safety-mode" \
"$ROOT/deployment/han-processing.service"
"$ROOT/deployment/han-processing.service" \
"$ROOT/deployment/kesl/kesl_scan_broker.py" \
"$ROOT/deployment/kesl/han-kesl-scan-broker.service" \
"$ROOT/deployment/kesl/han-kesl-scan-broker.socket" \
"$ROOT/deployment/kesl/han-kesl-scan-broker.tmpfiles.conf"
do
[ -f "$protected" ] || continue
owner=$(/usr/bin/stat -c '%U:%G' "$protected")
@@ -603,6 +603,10 @@ install_release_helpers_if_possible() {
install -m 0755 -o root -g root \
"${deployment}/han-message-safety-mode" \
/usr/local/sbin/han-message-safety-mode
install -d -m 0755 -o root -g root /usr/local/libexec
install -m 0750 -o root -g root \
"${deployment}/kesl/kesl_scan_broker.py" \
/usr/local/libexec/han-kesl-scan-broker
install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy
install -m 0755 -o root -g root \
"${deployment}/scripts/ssl-renew-deploy-hook.sh" \
@@ -613,6 +617,16 @@ install_release_helpers_if_possible() {
install -m 0644 -o root -g root \
"${deployment}/han-processing.service" \
/etc/systemd/system/han-processing.service
install -m 0644 -o root -g root \
"${deployment}/kesl/han-kesl-scan-broker.service" \
/etc/systemd/system/han-kesl-scan-broker.service
install -m 0644 -o root -g root \
"${deployment}/kesl/han-kesl-scan-broker.socket" \
/etc/systemd/system/han-kesl-scan-broker.socket
install -m 0644 -o root -g root \
"${deployment}/kesl/han-kesl-scan-broker.tmpfiles.conf" \
/etc/tmpfiles.d/han-kesl-scan-broker.conf
systemd-tmpfiles --create /etc/tmpfiles.d/han-kesl-scan-broker.conf
safety_sudoers="$(mktemp)"
sed 's/\r$//' "${deployment}/deploy-message-safety-mode.sudoers" >"$safety_sudoers"
chmod 0440 "$safety_sudoers"
@@ -22,8 +22,7 @@ x-message-safety-environment: &message-safety-environment
MESSAGE_SAFETY_PORT: ${MESSAGE_SAFETY_PORT:-8080}
MESSAGE_SAFETY_WORKER_CONCURRENCY: ${MESSAGE_SAFETY_WORKER_CONCURRENCY:-5}
MESSAGE_SAFETY_DNS_RESOLVERS: ${MESSAGE_SAFETY_DNS_RESOLVERS:?set trusted DNS resolvers}
MESSAGE_SAFETY_CLAMAV_HOST: ${MESSAGE_SAFETY_CLAMAV_HOST:-clamd}
MESSAGE_SAFETY_CLAMAV_PORT: ${MESSAGE_SAFETY_CLAMAV_PORT:-3310}
MESSAGE_SAFETY_ANTIVIRUS_SOCKET: /run/han-kesl/scan.sock
MESSAGE_SAFETY_ARTIFACTS_DIR: ${MESSAGE_SAFETY_ARTIFACTS_DIR:-/app/app/artifacts}
MESSAGE_SAFETY_MODE_FILE: /etc/han-chat/message-safety-mode.env
PG_CA_FILE: /run/config/postgresql-ca.pem
@@ -169,49 +168,6 @@ services:
mem_limit: 64m
cpus: 0.25
clamd:
<<: *hardening
image: ${CLAMAV_IMAGE:?set immutable ClamAV image digest}
entrypoint: ["/init-unprivileged"]
user: "100:101"
command: ["clamd", "--foreground=true"]
volumes:
- clamav-signatures:/var/lib/clamav:ro
- clamav-runtime:/run/clamav
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
- /var/log/clamav:rw,noexec,nosuid,nodev,size=32m,uid=100,gid=101,mode=0750
networks: [backend]
expose: ["3310"]
healthcheck:
test: ["CMD-SHELL", "clamdscan --ping 1 >/dev/null 2>&1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
pids_limit: 300
mem_limit: 2g
cpus: 2.0
freshclam:
<<: *hardening
image: ${CLAMAV_IMAGE:?set immutable ClamAV image digest}
entrypoint: ["/init-unprivileged"]
user: "100:101"
command: ["freshclam", "--daemon", "--foreground", "--checks=12"]
volumes:
- clamav-signatures:/var/lib/clamav
- clamav-runtime:/run/clamav
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
- /var/log/clamav:rw,noexec,nosuid,nodev,size=32m,uid=100,gid=101,mode=0750
healthcheck:
disable: true
networks: [signature-egress]
pids_limit: 100
mem_limit: 256m
cpus: 0.5
otel-queue-init:
<<: *hardening
image: ${REDIS_IMAGE:?set immutable Redis image digest}
@@ -270,6 +226,7 @@ services:
MESSAGE_SAFETY_PROCESS_ROLE: api
volumes:
- /etc/han-chat/message-safety-mode.env:/etc/han-chat/message-safety-mode.env:ro
- /run/han-kesl:/run/han-kesl:ro
- *postgres-ca-volume
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
@@ -306,6 +263,7 @@ services:
MESSAGE_SAFETY_PROCESS_ROLE: worker
volumes:
- /etc/han-chat/message-safety-mode.env:/etc/han-chat/message-safety-mode.env:ro
- /run/han-kesl:/run/han-kesl:ro
- *postgres-ca-volume
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=256m
@@ -322,8 +280,6 @@ services:
depends_on:
redis-safety:
condition: service_healthy
clamd:
condition: service_healthy
pids_limit: 400
mem_limit: 2g
cpus: 2.0
@@ -465,13 +421,10 @@ networks:
internal: true
safety-egress:
bitrix-egress:
signature-egress:
telemetry-egress:
volumes:
redis-safety-data:
clamav-signatures:
clamav-runtime:
otel-queue:
secrets:
@@ -30,8 +30,16 @@ message-safety-config activate --version 1 --approved-by security-owner
`docker-compose.fragment.yml` is an include fragment for the root VM2 Compose. It publishes no
host port, runs API and worker as UID 10001 with a read-only filesystem, drops all capabilities,
and mounts only service-specific secret files. The root project owns networks/secrets and the
root-owned emergency mode file.
and mounts only service-specific secret files plus the broker Unix socket
`/run/han-kesl/scan.sock` into the worker. The root project owns networks/secrets and the
root-owned emergency mode file. KESL 12.4 standalone and its root-owned fail-closed broker are
host services, not Compose services; `clamd` and `freshclam` are absent from Compose.
The broker invokes the fixed host command `kesl-control --scan-file --action Inform`. A clean
result may continue to allow, an infected result denies, and scanner errors, unknown output,
timeouts or a stale KESL database remain retryable and eventually return `503` rather than
allowing content. Runtime records `scanner_engine=kesl`; `signatures_version` is the hash of KESL
version plus database date. KESL database updates run hourly under the operator KESL runbook.
## External release gates
@@ -41,8 +49,10 @@ target environment verifies them:
- Selectel S3 supports version-specific `GetObject`, signed conditional ETag behavior, bucket
versioning, checksum metadata, virtual-host addressing and a read-only IAM policy without
list/write/delete.
- ClamAV engine/signature metadata is supplied to readiness and task cache keys; freshclam
activate/reload, signature-age alarms and clean/EICAR/malformed corpora pass on VM2.
- KESL version/database date is supplied through the broker to readiness and task cache keys;
hourly update, stale-database alarms and clean/EICAR/malformed corpora pass on VM2.
- The broker is a custom integration: exact `kesl-control` output/exit semantics, socket
permissions, cleanup and throughput must pass gates on the target VM2 with KESL 12.4.
- HEIF native decoding and PDF parser sandbox resource limits pass the approved corpus. The
in-process detector is bounded by 5 MiB and validates active/encrypted PDF markers, but OS-level
CPU/memory/wall-time isolation must be enforced by the worker container and target runtime.
@@ -96,6 +96,7 @@ def create_app(service: SafetyService, token: str) -> FastAPI:
@app.get("/health/ready")
async def ready() -> JSONResponse:
await service.refresh_antivirus()
mode = "mock" if service.mode.mock else "standard"
components = {
"postgres": "ok",
@@ -2,9 +2,9 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "rules_bundle_ref", "detector_manifest_ref", "task", "rate", "retention", "cache", "link", "clamav", "file_policy"],
"required": ["schema_version", "rules_bundle_ref", "detector_manifest_ref", "task", "rate", "retention", "cache", "link", "antivirus", "file_policy"],
"properties": {
"schema_version": {"const": 1},
"schema_version": {"const": 2},
"rules_bundle_ref": {"type": "string", "pattern": "^rules-[0-9]{4}-[0-9]{2}-[0-9]{2}$"},
"detector_manifest_ref": {"const": "detector-2026-08-03"},
"task": {
@@ -48,9 +48,13 @@
"pipeline_timeout_sec": {"type": "number", "exclusiveMinimum": 0, "maximum": 5}
}
},
"clamav": {
"type": "object", "additionalProperties": false, "required": ["scan_timeout_sec", "max_signature_age_hours"],
"properties": {"scan_timeout_sec": {"type": "integer", "minimum": 1, "maximum": 120}, "max_signature_age_hours": {"type": "integer", "minimum": 1, "maximum": 720}}
"antivirus": {
"type": "object", "additionalProperties": false, "required": ["engine", "scan_timeout_sec", "max_signature_age_hours"],
"properties": {
"engine": {"const": "kesl"},
"scan_timeout_sec": {"type": "integer", "minimum": 1, "maximum": 300},
"max_signature_age_hours": {"type": "integer", "minimum": 1, "maximum": 720}
}
},
"file_policy": {
"type": "object", "additionalProperties": false, "required": ["enabled_mime_types", "max_size_bytes"],
@@ -1,4 +1,4 @@
schema_version: 1
schema_version: 2
rules_bundle_ref: rules-2026-01-01
detector_manifest_ref: detector-2026-08-03
task:
@@ -21,8 +21,9 @@ link:
url_max_length: 2048
dns_lookup_timeout_sec: 1
pipeline_timeout_sec: 2
clamav:
scan_timeout_sec: 45
antivirus:
engine: kesl
scan_timeout_sec: 60
max_signature_age_hours: 240
file_policy:
enabled_mime_types:
@@ -7,6 +7,8 @@ import json
import struct
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
from pathlib import Path
from typing import Protocol
@@ -33,7 +35,24 @@ class ObjectReader(Protocol):
class Antivirus(Protocol):
async def scan(self, chunks: AsyncIterator[bytes]) -> str | None: ...
async def scan(
self, chunks: AsyncIterator[bytes], *, scan_timeout: float
) -> AntivirusScanResult: ...
async def status(self) -> AntivirusStatus: ...
@dataclass(frozen=True)
class AntivirusScanResult:
threat: str | None
signatures_version: str
@dataclass(frozen=True)
class AntivirusStatus:
engine_version: str
signatures_version: str
databases_date: datetime
@dataclass(frozen=True)
@@ -119,65 +138,101 @@ async def collect_and_hash(
return bytes(body), digest.digest()
class ClamAvInstream:
def __init__(self, host: str, port: int, timeout: float = 45.0) -> None:
self.host, self.port, self.timeout = host, port, timeout
class KeslSocketScanner:
MAX_HEADER = 4096
MAX_RESPONSE = 16 * 1024
async def scan(self, chunks: AsyncIterator[bytes]) -> str | None:
def __init__(self, socket_path: Path, timeout: float = 60.0) -> None:
self.socket_path = socket_path
self.timeout = timeout
async def _request(
self,
header: dict[str, object],
body: bytes = b"",
*,
request_timeout: float | None = None,
) -> dict[str, object]:
encoded = json.dumps(header, separators=(",", ":")).encode()
if len(encoded) > self.MAX_HEADER:
raise DependencyFailure("KESL request header is too large")
async def operation() -> dict[str, object]:
reader, writer = await asyncio.open_unix_connection(str(self.socket_path))
try:
writer.write(struct.pack(">I", len(encoded)) + encoded + body)
await writer.drain()
(size,) = struct.unpack(">I", await reader.readexactly(4))
if size < 2 or size > self.MAX_RESPONSE:
raise DependencyFailure("invalid KESL broker response size")
value = json.loads((await reader.readexactly(size)).decode())
if not isinstance(value, dict):
raise DependencyFailure("invalid KESL broker response")
return value
finally:
writer.close()
await writer.wait_closed()
try:
return await asyncio.wait_for(operation(), request_timeout or self.timeout)
except (OSError, TimeoutError, asyncio.IncompleteReadError, json.JSONDecodeError) as exc:
raise DependencyFailure("KESL unavailable") from exc
async def scan(
self, chunks: AsyncIterator[bytes], *, scan_timeout: float
) -> AntivirusScanResult:
body = bytearray()
async for chunk in chunks:
body.extend(chunk)
with tracer.start_as_current_span(
"message_safety.clamav.scan",
attributes={"server.address.type": "clamav"},
"message_safety.antivirus.scan",
attributes={"message_safety.antivirus.engine": "kesl"},
):
return await self._scan(chunks)
async def _scan(self, chunks: AsyncIterator[bytes]) -> str | None:
async def operation() -> str | None:
reader, writer = await asyncio.open_connection(self.host, self.port)
try:
writer.write(b"zINSTREAM\0")
async for chunk in chunks:
writer.write(struct.pack(">I", len(chunk)) + chunk)
await writer.drain()
writer.write(struct.pack(">I", 0))
await writer.drain()
result = await reader.readuntil(b"\0")
text = result.rstrip(b"\0").decode("utf-8", "replace")
if text.endswith(" OK"):
return None
if text.endswith(" FOUND"):
return text.rsplit(": ", 1)[-1].removesuffix(" FOUND")
raise DependencyFailure("invalid ClamAV response")
finally:
writer.close()
await writer.wait_closed()
try:
return await asyncio.wait_for(operation(), self.timeout)
except (OSError, TimeoutError) as exc:
raise DependencyFailure("ClamAV unavailable") from exc
async def signatures_version(self) -> str:
with tracer.start_as_current_span("message_safety.clamav.version"):
return await self._signatures_version()
async def _signatures_version(self) -> str:
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(self.host, self.port), 2.0
value = await self._request(
{"op": "scan", "size": len(body)},
bytes(body),
request_timeout=scan_timeout,
)
if value.get("status") != "completed" or value.get("verdict") not in {
"clean",
"infected",
}:
raise DependencyFailure("KESL scan failed")
signatures = value.get("signatures_version")
if not isinstance(signatures, str) or not signatures.startswith("sha256:"):
raise DependencyFailure("KESL signatures version is missing")
threat = value.get("threat")
if value["verdict"] == "infected" and not isinstance(threat, str):
raise DependencyFailure("KESL infected verdict has no threat")
return AntivirusScanResult(
threat=threat if isinstance(threat, str) else None,
signatures_version=signatures,
)
async def status(self) -> AntivirusStatus:
with tracer.start_as_current_span(
"message_safety.antivirus.status",
attributes={"message_safety.antivirus.engine": "kesl"},
):
value = await self._request({"op": "status"})
if value.get("status") != "ready":
raise DependencyFailure("KESL is not ready")
try:
database_date = datetime.fromisoformat(
str(value["databases_date"]).replace("Z", "+00:00")
)
except ValueError:
try:
writer.write(b"zVERSION\0")
await writer.drain()
raw = await asyncio.wait_for(reader.readuntil(b"\0"), 2.0)
finally:
writer.close()
await writer.wait_closed()
except (OSError, TimeoutError) as exc:
raise DependencyFailure("ClamAV unavailable") from exc
value = raw.rstrip(b"\0")
if not value.startswith(b"ClamAV ") or len(value) > 512:
raise DependencyFailure("invalid ClamAV version response")
return "sha256:" + hashlib.sha256(value).hexdigest()
database_date = parsedate_to_datetime(str(value["databases_date"]))
except (TypeError, ValueError) as exc:
raise DependencyFailure("invalid KESL database date") from exc
if database_date.tzinfo is None:
database_date = database_date.replace(tzinfo=UTC)
engine = value.get("engine_version")
signatures = value.get("signatures_version")
if not isinstance(engine, str) or not isinstance(signatures, str):
raise DependencyFailure("incomplete KESL status")
return AntivirusStatus(engine, signatures, database_date.astimezone(UTC))
async def one_chunk(data: bytes) -> AsyncIterator[bytes]:
@@ -8,7 +8,7 @@ from app.adapters import TrustedDnsResolver
from app.api import create_app
from app.config import ActiveConfig, validate_config
from app.db import engine_and_sessions
from app.file_pipeline import ClamAvInstream, DependencyFailure
from app.file_pipeline import DependencyFailure, KeslSocketScanner
from app.repository import Repository
from app.service import SafetyService
from app.settings import BootstrapSettings, EmergencyMode
@@ -29,13 +29,14 @@ async def build_runtime() -> tuple[object, object]:
resolver = TrustedDnsResolver(
[item.strip() for item in settings.dns_resolvers.split(",") if item.strip()]
)
clamav = ClamAvInstream(settings.clamav_host, settings.clamav_port)
antivirus = KeslSocketScanner(settings.antivirus_socket)
if mode.mock:
signatures_version = "unavailable"
files_ready = False
else:
try:
signatures_version = await clamav.signatures_version()
status = await antivirus.status()
signatures_version = status.signatures_version
files_ready = True
except DependencyFailure:
signatures_version = "unavailable"
@@ -47,6 +48,7 @@ async def build_runtime() -> tuple[object, object]:
resolver,
files_ready=files_ready,
signatures_version=signatures_version,
antivirus=antivirus,
)
app = create_app(service, settings.service_token.get_secret_value())
instrument_fastapi(app)
@@ -90,7 +90,7 @@ class Repository:
FileVerdictCache.config_version == config.version,
FileVerdictCache.rules_version == config.rules_version,
FileVerdictCache.detector_version == config.detector.version,
FileVerdictCache.scanner_engine == "clamav",
FileVerdictCache.scanner_engine == "kesl",
FileVerdictCache.signatures_version == signatures_version,
FileVerdictCache.expires_at > func.now(),
)
@@ -262,9 +262,29 @@ class Repository:
return result.rowcount == 1
async def finish(
self, task_id: uuid.UUID, owner: str, generation: int, *, allow: bool, rule_id: str
self,
task_id: uuid.UUID,
owner: str,
generation: int,
*,
allow: bool,
rule_id: str,
signatures_version: str | None = None,
) -> bool:
now = datetime.now(UTC)
values = {
"status": TaskStatus.allowed if allow else TaskStatus.denied,
"verdict": "allow" if allow else "deny",
"rule_id": rule_id,
"reason_code": None if allow else "message_blocked",
"finished_at": now,
"purge_after": now + timedelta(days=30),
"updated_at": now,
"lease_owner": None,
"lease_until": None,
}
if signatures_version is not None:
values["signatures_version"] = signatures_version
async with self.sessions.begin() as session:
result = await session.execute(
update(SafetyTask)
@@ -275,17 +295,7 @@ class Repository:
SafetyTask.lease_generation == generation,
SafetyTask.lease_until > func.now(),
)
.values(
status=TaskStatus.allowed if allow else TaskStatus.denied,
verdict="allow" if allow else "deny",
rule_id=rule_id,
reason_code=None if allow else "message_blocked",
finished_at=now,
purge_after=now + timedelta(days=30),
updated_at=now,
lease_owner=None,
lease_until=None,
)
.values(**values)
)
if result.rowcount == 1:
await session.execute(
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import uuid
from datetime import UTC, datetime, timedelta
from time import perf_counter
@@ -14,7 +15,7 @@ from app.db import (
TaskStatus,
TextRulesCache,
)
from app.file_pipeline import validate_metadata
from app.file_pipeline import Antivirus, DependencyFailure, validate_metadata
from app.fingerprint import fingerprint
from app.normalization import normalize_text
from app.rate_limit import ConservativeRateLimiter
@@ -52,6 +53,7 @@ class SafetyService:
links_ready: bool = True,
files_ready: bool = True,
signatures_version: str = "unverified",
antivirus: Antivirus | None = None,
) -> None:
self.repository = repository
self.config = config
@@ -60,11 +62,40 @@ class SafetyService:
self.links_ready = links_ready
self.files_ready = files_ready
self.signatures_version = signatures_version
self.antivirus = antivirus
self._antivirus_checked_at = 0.0
self._antivirus_check_lock = asyncio.Lock()
self.rate_limiter = ConservativeRateLimiter(
config.document["rate"]["text_rps"], config.document["rate"]["file_rps"]
)
record_runtime_state("mock" if mode.mock else "standard", config.version)
async def refresh_antivirus(self) -> bool:
if self.mode.mock:
self.files_ready = False
return False
if self.antivirus is None:
return self.files_ready
if perf_counter() - self._antivirus_checked_at < 5:
return self.files_ready
async with self._antivirus_check_lock:
if perf_counter() - self._antivirus_checked_at < 5:
return self.files_ready
try:
status = await self.antivirus.status()
maximum_age = timedelta(
hours=self.config.document["antivirus"]["max_signature_age_hours"]
)
if datetime.now(UTC) - status.databases_date > maximum_age:
raise DependencyFailure("KESL databases are stale")
self.signatures_version = status.signatures_version
self.files_ready = True
except DependencyFailure:
self.signatures_version = "unavailable"
self.files_ready = False
self._antivirus_checked_at = perf_counter()
return self.files_ready
def _verdict(
self,
allow: bool,
@@ -222,7 +253,7 @@ class SafetyService:
)
async def _check_file(self, request: FileCheck, digest: bytes) -> Verdict | Pending:
if not self.files_ready:
if not await self.refresh_antivirus():
raise CapabilityUnavailable("files")
rule = validate_metadata(
request.attachment,
@@ -274,7 +305,7 @@ class SafetyService:
declared_checksum=request.attachment.checksum,
rules_version=self.config.rules_version,
detector_version=self.config.detector.version,
scanner_engine="clamav",
scanner_engine="kesl",
signatures_version=self.signatures_version,
origin_trace_id=trace_id,
origin_span_id=span_id,
@@ -32,8 +32,10 @@ class BootstrapSettings(BaseSettings):
default=5, ge=1, le=32, alias="MESSAGE_SAFETY_WORKER_CONCURRENCY"
)
dns_resolvers: str = Field(default="", alias="MESSAGE_SAFETY_DNS_RESOLVERS")
clamav_host: str = Field(default="clamd", alias="MESSAGE_SAFETY_CLAMAV_HOST")
clamav_port: int = Field(default=3310, ge=1, le=65535, alias="MESSAGE_SAFETY_CLAMAV_PORT")
antivirus_socket: Path = Field(
default=Path("/run/han-kesl/scan.sock"),
alias="MESSAGE_SAFETY_ANTIVIRUS_SOCKET",
)
s3_endpoint_url: str = Field(alias="SELECTEL_S3_ENDPOINT_URL")
s3_bucket: str = Field(alias="SELECTEL_S3_BUCKET_QUARANTINE")
artifacts_dir: Path = Field(
@@ -12,8 +12,9 @@ from app.config import validate_config
from app.contracts import Attachment
from app.db import FileVerdictCache, SafetyAudit, engine_and_sessions
from app.file_pipeline import (
ClamAvInstream,
Antivirus,
DependencyFailure,
KeslSocketScanner,
ObjectChanged,
collect_and_hash,
detect_format,
@@ -34,7 +35,7 @@ tracer = trace.get_tracer("message-safety.worker")
class Worker:
def __init__(
self, repository: Repository, reader: S3VersionReader, antivirus: ClamAvInstream, artifacts
self, repository: Repository, reader: S3VersionReader, antivirus: Antivirus, artifacts
) -> None:
self.repository, self.reader, self.antivirus, self.artifacts = (
repository,
@@ -94,10 +95,15 @@ class Worker:
)
record_dependency("s3", "get_object", "success")
rule = detect_format(body, attachment.mime_type)
signatures_version = task.signatures_version
if not rule:
malware = await self.antivirus.scan(one_chunk(body))
record_dependency("clamav", "scan", "success")
rule = "file.malware_detected" if malware else None
scan = await self.antivirus.scan(
one_chunk(body),
scan_timeout=row.config["antivirus"]["scan_timeout_sec"],
)
signatures_version = scan.signatures_version
record_dependency("antivirus", "scan", "success")
rule = "file.malware_detected" if scan.threat else None
with tracer.start_as_current_span("message_safety.worker.finalize"):
finished = await self.repository.finish(
task.id,
@@ -105,6 +111,7 @@ class Worker:
task.lease_generation,
allow=rule is None,
rule_id=rule or "safety.all_checks_passed",
signatures_version=signatures_version,
)
if finished:
record_worker("allow" if rule is None else "deny", task_age)
@@ -116,7 +123,7 @@ class Worker:
rules_version=task.rules_version,
detector_version=task.detector_version,
scanner_engine=task.scanner_engine,
signatures_version=task.signatures_version,
signatures_version=signatures_version,
verdict="allow" if rule is None else "deny",
rule_id=rule or "safety.all_checks_passed",
reason_code=None if rule is None else "message_blocked",
@@ -192,7 +199,7 @@ async def serve() -> None:
settings.s3_access_key.get_secret_value(),
settings.s3_secret_key.get_secret_value(),
),
ClamAvInstream(settings.clamav_host, settings.clamav_port),
KeslSocketScanner(settings.antivirus_socket),
settings.artifacts_dir,
)
async with asyncio.TaskGroup() as group:
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import httpx
@@ -7,6 +8,7 @@ import pytest
from app.api import create_app
from app.db import TaskStatus
from app.file_pipeline import AntivirusStatus
from app.repository import ConflictError
from app.service import SafetyService
from app.settings import EmergencyMode
@@ -54,6 +56,15 @@ class ForbiddenResolver:
raise AssertionError("MOCK must not call DNS")
class StaleAntivirus:
async def status(self):
return AntivirusStatus(
"12.4",
"sha256:" + "a" * 64,
datetime.now(UTC) - timedelta(hours=241),
)
def body(kind: str, message_id=None) -> dict:
value = {
"message_id": str(message_id or uuid4()),
@@ -188,3 +199,27 @@ async def test_final_task_response_keeps_task_config_snapshot(active_config) ->
assert final.status_code == 200
assert final.json()["config_version"] == 99
async def test_stale_kesl_databases_disable_only_files(active_config) -> None:
repo = FakeRepository()
service = SafetyService(
repo,
active_config,
EmergencyMode(),
ForbiddenResolver(),
antivirus=StaleAntivirus(),
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=create_app(service, "secret")),
base_url="http://test",
headers={"X-Service-Token": "secret"},
) as client:
file_response = await client.post(
"/internal/safety/v2/messages/check", json=body("file")
)
text_response = await client.post(
"/internal/safety/v2/messages/check", json=body("text")
)
assert file_response.status_code == 503
assert text_response.status_code == 200
@@ -35,12 +35,13 @@ def test_config_cross_field_and_manifest_subset(artifacts: Path) -> None:
validate_config(bad, artifacts)
def test_clamav_signature_age_policy_bounds(artifacts: Path) -> None:
def test_kesl_signature_age_policy_bounds(artifacts: Path) -> None:
document = seed(artifacts)
assert document["clamav"]["max_signature_age_hours"] == 240
document["clamav"]["max_signature_age_hours"] = 720
assert document["antivirus"]["engine"] == "kesl"
assert document["antivirus"]["max_signature_age_hours"] == 240
document["antivirus"]["max_signature_age_hours"] = 720
validate_config(document, artifacts)
document["clamav"]["max_signature_age_hours"] = 721
document["antivirus"]["max_signature_age_hours"] = 721
with pytest.raises(ValidationError):
validate_config(document, artifacts)
@@ -1,14 +1,24 @@
from __future__ import annotations
import asyncio
import hashlib
import io
import json
import struct
from pathlib import Path
from uuid import UUID
import pytest
from PIL import Image
from app.contracts import Attachment
from app.file_pipeline import ObjectChanged, collect_and_hash, detect_format
from app.file_pipeline import (
KeslSocketScanner,
ObjectChanged,
collect_and_hash,
detect_format,
one_chunk,
)
def image_bytes(format_name: str) -> bytes:
@@ -71,3 +81,65 @@ async def test_authoritative_stream_hash_and_size() -> None:
assert body == data and digest == hashlib.sha256(data).digest()
with pytest.raises(ObjectChanged):
await collect_and_hash(Reader(data), attachment(data, size=len(data) + 1), max_size=100)
class FakeWriter:
def __init__(self) -> None:
self.request = bytearray()
def write(self, value: bytes) -> None:
self.request.extend(value)
async def drain(self) -> None:
return None
def close(self) -> None:
return None
async def wait_closed(self) -> None:
return None
def framed(value: dict[str, object]):
body = json.dumps(value).encode()
reader = __import__("asyncio").StreamReader()
reader.feed_data(struct.pack(">I", len(body)) + body)
reader.feed_eof()
return reader
async def test_kesl_socket_clean_infected_and_status(monkeypatch, tmp_path: Path) -> None:
responses = [
{
"status": "completed",
"verdict": "clean",
"threat": None,
"engine_version": "12.4",
"signatures_version": "sha256:" + "a" * 64,
},
{
"status": "completed",
"verdict": "infected",
"threat": "EICAR-Test-File",
"engine_version": "12.4",
"signatures_version": "sha256:" + "b" * 64,
},
{
"status": "ready",
"engine_version": "12.4",
"databases_date": "2026-09-07T11:25:00+00:00",
"signatures_version": "sha256:" + "c" * 64,
},
]
async def connect(_):
return framed(responses.pop(0)), FakeWriter()
monkeypatch.setattr(asyncio, "open_unix_connection", connect, raising=False)
scanner = KeslSocketScanner(tmp_path / "scan.sock")
clean = await scanner.scan(one_chunk(b"clean"), scan_timeout=1)
infected = await scanner.scan(one_chunk(b"eicar"), scan_timeout=1)
status = await scanner.status()
assert clean.threat is None
assert infected.threat == "EICAR-Test-File"
assert status.engine_version == "12.4"
@@ -31,7 +31,7 @@ Test-only правила по первому символу, случайные
| Safety Service Owner | lifecycle v2, API/data contracts, service-owned config, capacity и cutover sign-off |
| Rule Pack Owner | версия rules bundle, corpus, monitor rollout и release notes |
| Security Owner | approval `monitor → deny` и config changes, ослабляющих policy; threat model, egress/secrets и risk acceptance |
| Operations Owner | VM2, ClamAV signatures, alerts, rollback/reprovision и restore rehearsal |
| Operations Owner | VM2, host KESL/broker, антивирусные базы, alerts, rollback/reprovision и restore rehearsal |
Один человек может выполнять несколько ролей, но для каждого production release роли и approvals должны быть записаны в release checklist.
@@ -45,7 +45,7 @@ Test-only правила по первому символу, случайные
- валидацию file metadata и фактического формата;
- чтение файла из S3-quarantine по read-only credentials;
- вычисление authoritative SHA-256;
- антивирусную проверку файла через ClamAV;
- антивирусную проверку файла через root-owned fail-closed broker по Unix socket `/run/han-kesl/scan.sock`; broker вызывает host KESL 12.4 standalone;
- выбор sync/async режима;
- создание и исполнение async safety tasks;
- sticky final verdict, verdict cache и audit в схеме `message_safety`;
@@ -78,7 +78,7 @@ Test-only правила по первому символу, случайные
| `MOCK=true`, `TEXT_FREE=false`, `FILE_FREE=true` | forced `403 deny` | forced `200 allow` |
| `MOCK=true`, `TEXT_FREE=false`, `FILE_FREE=false` | forced `403 deny` | forced `403 deny` |
При `MOCK=true` не выполняются normalization/rules, URL extraction/DNS, S3 read/checksum/format/ClamAV, verdict caches, task creation и async worker. Service authentication, body-size/JSON/strict DTO validation, idempotency conflict protection, PostgreSQL audit и rate limits остаются обязательными controls.
При `MOCK=true` не выполняются normalization/rules, URL extraction/DNS, S3 read/checksum/format/KESL scan, verdict caches, task creation и async worker. Service authentication, body-size/JSON/strict DTO validation, idempotency conflict protection, PostgreSQL audit и rate limits остаются обязательными controls.
Forced allow использует `rule_id=safety.mock_forced_allow`; forced deny — `rule_id=safety.mock_forced_deny`, `reason_code=message_blocked`. Mock никогда не возвращает `202`. Internal response содержит `processing_mode=mock`; `api-backend` не раскрывает mode/rule клиенту. Mode фиксируется при первом принятии `message_id`: ранее созданный standard task/idempotency result не переклассифицируется и завершается в standard, а новый mock request не создаёт task. Это исключает смену verdict посередине обработки.
@@ -118,8 +118,8 @@ Rules не заменяют безопасный rendering. Frontend и Bitrix i
| Недопустимый размер/MIME | Сверка DTO с allow-list и лимитами | `403 deny` |
| Подмена MIME | Magic-byte/content sniffing, сверка declared MIME | `403 deny` |
| Подмена содержимого после complete | Version-specific read + ETag + полный SHA-256 | `403 deny` |
| Malware | ClamAV scan актуальными сигнатурами | `403 deny` |
| Архивная бомба/ресурсное истощение | Лимиты размера, stream scan, ClamAV limits/timeouts | deny при policy hit; error при сбое |
| Malware | Host KESL 12.4 scan через fail-closed broker с актуальной базой | `403 deny` |
| Архивная бомба/ресурсное истощение | Лимиты размера, bounded staging и KESL/broker limits/timeouts | deny при policy hit; error при сбое |
| Polyglot/неоднозначный формат | Строгий формат detector и deny при mismatch/ambiguity | `403 deny` |
| Повтор известного файла | Cache по SHA-256 + versions | Sticky cached verdict |
@@ -179,7 +179,7 @@ flowchart TD
task --> worker[File_worker]
worker --> objectRead[S3_stream_and_SHA256]
objectRead --> formatCheck[Format_validation]
formatCheck --> avScan[ClamAV_scan]
formatCheck --> avScan[KESL_broker_scan]
avScan --> finalVerdict[Persist_sticky_verdict]
finalVerdict --> taskGet[GET_task_200_or_403]
```
@@ -255,7 +255,7 @@ Rules поставляются как статический read-only bundle `a
| `file.encrypted_content` | file_content | deny | encrypted/password-protected container |
| `file.active_content` | file_content | deny | PDF JavaScript/OpenAction/Launch/XFA/embedded |
| `file.parser_limit` | file_content | deny | parser resource/decompression/object limit |
| `file.malware_detected` | file_content | deny | ClamAV FOUND |
| `file.malware_detected` | file_content | deny | KESL сообщает infected |
Все domain deny используют `reason_code=message_blocked`; детализация остаётся во внутреннем `rule_id`. DTO/schema/authorization errors не получают `rule_id`.
@@ -389,29 +389,28 @@ Worker:
5. определяет реальный формат по содержимому;
6. сверяет detector result с declared MIME;
7. запрещает encrypted/password-protected и неподдерживаемые containers;
8. передаёт поток в `clamd` через internal network;
8. передаёт bounded file broker-у через Unix socket `/run/han-kesl/scan.sock`; root-owned broker вызывает `kesl-control --scan-file --action Inform`;
9. сохраняет sticky final verdict и audit;
10. записывает cache только для terminal результата;
11. завершает lease.
Чистый файл получает allow только если успешно завершились **все** обязательные проверки. `ClamAV FOUND`, checksum mismatch, format mismatch, unsupported encrypted content или policy limit дают deny с отдельным `rule_id`.
Чистый файл получает allow только если успешно завершились **все** обязательные проверки и broker однозначно вернул `clean`. KESL `infected`, checksum mismatch, format mismatch, unsupported encrypted content или policy limit дают deny с отдельным `rule_id`.
ClamAV timeout, protocol error, недоступность S3/DB/Redis или потеря lease не являются deny. Task остаётся pending/retryable в пределах deadline; после исчерпания retry получает terminal infrastructure failure, который API отдаёт как `503`, а не `403`.
KESL/broker timeout, ошибка запуска или разбора результата `kesl-control`, stale database, недоступность S3/DB/Redis или потеря lease не являются ни allow, ни domain deny. Task остаётся pending/retryable в пределах deadline; после исчерпания retry получает terminal infrastructure failure, который API отдаёт как `503`, а не `403`.
### 7.3. AV runtime
### 7.3. KESL runtime и broker
MVP использует отдельный `clamd` sidecar/service в private Docker network:
MVP использует установленный на VM2 host KESL 12.4 standalone и отдельный custom integration broker:
- порт не публикуется наружу;
- сигнатуры обновляет `freshclam`;
- readiness требует daemon PING и допустимый возраст signatures;
- `max_signature_age_hours` допускается в диапазоне `1..720` часов
(не более 30 дней); seed policy использует `240` часов (10 дней);
- limits согласованы с максимальным размером файла;
- контейнер non-root, read-only root filesystem где возможно, отдельный writable volume только для signatures/runtime;
- worker не передаёт в clamd object key, имя пользователя или иные PII.
- KESL и broker не входят в Compose; `clamd`/`freshclam`, их volumes, healthchecks и egress из Compose удалены;
- root-owned broker слушает только Unix socket `/run/han-kesl/scan.sock`; ожидаемые права socket — `root:han-message-safety 0660`, socket монтируется в worker, TCP listener отсутствует;
- broker fail-closed: принимает только bounded scan request, не принимает произвольные command/arguments/path traversal и вызывает фиксированный `kesl-control --scan-file --action Inform`;
- `clean` разрешает продолжить allow-ветку, `infected` даёт domain deny; неизвестный формат/exit code, timeout, недоступность KESL и stale database дают retry, затем `503`;
- readiness проверяет broker, KESL version/database date и допустимый возраст базы; `max_signature_age_hours` допускается в диапазоне `1..720`, seed — `240`;
- KESL обновляет базы на host ежечасно по операторскому KESL runbook; egress к источникам обновления принадлежит host KESL, не Compose;
- worker не передаёт object key, имя пользователя или иные PII; временный файл и broker state очищаются по завершении.
Недоступность AV переводит capability `files` в `unavailable` и запрещает новые file allow, но оставляет core/text readiness доступной.
Broker — custom integration: точный формат и exit semantics `kesl-control`, безопасная передача файла, очистка и throughput обязаны пройти gates на target VM2 с фактическим KESL 12.4. Недоступность scanner переводит capability `files` в `unavailable` и запрещает новые file allow, но оставляет core/text readiness доступной.
### 7.4. Исполнимая матрица форматов
@@ -425,7 +424,7 @@ MVP использует отдельный `clamd` sidecar/service в private D
Parser выполняется с CPU/memory/wall-time limits в отдельном sandboxed subprocess. Detector ambiguity, resource limit и disagreement detector↔declared MIME дают соответствующий `403 rule_id`. Immutable `detector-manifest.json` содержит bundle version, package/native library versions, supported MIME и hard limits; `detector_version` вычисляется как hash manifest, а не задаётся env. Active config ссылается на доступные `rules_version`/`detector_version` и может только сузить MIME/limits. Изменение manifest или active file policy инвалидирует file verdict cache через versioned cache key.
`scanner_engine=clamav`. `signatures_version` формируется из ClamAV engine version и CVD/CLD metadata/hash после успешного `freshclam` activate/reload; значение входит в verdict/cache/audit и readiness.
`scanner_engine=kesl`. `signatures_version` — стабильный hash канонической строки из KESL version и database date, полученных после успешной проверки broker/KESL; значение входит в verdict/cache/audit и readiness.
## 8. Internal API
@@ -697,14 +696,14 @@ Public `422` содержит стандартный error envelope без inter
- strict DTO, body/text/URL/file limits и запрет unknown fields;
- read-only S3-quarantine access, без list/write/delete;
- **запрет HTTP fetch/render/redirect follow** к пользовательским URL; link pipeline использует только доверенный DNS;
- egress по назначению: worker → DNS/S3/PostgreSQL, app → local Redis/OTLP/clamd; внешнего reputation API нет;
- egress по назначению: worker → DNS/S3/PostgreSQL, app → local Redis/OTLP; KESL update egress принадлежит host и ограничен операторским KESL runbook; внешнего reputation API нет;
- parameterized SQL и least-privilege DB role только на schema `message_safety`;
- Redis Safety ACL только prefixes `han:safety:*`;
- dependency pinning, SBOM/image scanning;
- non-root, read-only root fs, tmpfs `/tmp`, dropped capabilities, no-new-privileges;
- OpenAPI UI выключен в production;
- безопасные generic errors без stack/internal addresses;
- правила и AV signatures обновляются только trusted deployment process.
- rules и KESL database обновляются только trusted deployment/operator process.
- MOCK не имеет runtime/public API; mode file root-owned, а `deploy` имеет sudo только на argument-validating helper и approved restart.
## 13. Observability и privacy
@@ -726,10 +725,10 @@ Metrics:
- `message_safety_mock_enabled` gauge и forced outcomes по `text|file`/`allow|deny`;
- rule hits по low-cardinality `rule_id`;
- task queue/age/attempts/lease conflicts/timeouts;
- scan latency/bytes buckets/AV outcome;
- scan latency/bytes buckets/KESL outcome;
- signatures age/version info;
- cache hit/miss;
- PostgreSQL/Redis/S3/ClamAV latency and errors;
- PostgreSQL/Redis/S3/KESL broker latency and errors;
- active/used `config_version`, activation result и config refresh age;
- auth rejects/rate limit/readiness.
@@ -748,7 +747,7 @@ Telemetry collector unavailable не влияет на safety verdict и readine
- Redis Safety status как degraded accelerator, не core gate;
- worker heartbeat/lease processing;
- S3-quarantine Head/Get read permission на безопасный canary object;
- ClamAV PING и допустимый возраст signatures;
- broker socket, KESL status и допустимый возраст database;
- trusted DNS resolver для `links`;
- OpenAPI/runtime parity проверяется на startup/CI, а не сетевым probe каждого ready request.
@@ -777,7 +776,7 @@ Telemetry collector unavailable не влияет на safety verdict и readine
}
```
Core `HTTP 503 status=not_ready` используется только для invalid config/rules/PostgreSQL. Потеря worker heartbeat делает `worker=unavailable` и `files=unavailable`, но сохраняет text/links; ClamAV/S3 down выключает только `files`; DNS down — только `links`; Redis down прогревается из PostgreSQL и не выключает core. Каждый POST повторно проверяет требуемую capability и остаётся источником correctness; api-backend может кэшировать health snapshot не дольше 5 с только для fast-fail. Через private nginx ВМ2 endpoint доступен как exact `GET /internal/safety/status`, который проксируется в `/health/ready`; прямой `/health/ready` остаётся локальным container health. Private alias ограничен SG/source allow-list, не требует service token и не раскрывает credentials/hostnames.
Core `HTTP 503 status=not_ready` используется только для invalid config/rules/PostgreSQL. Потеря worker heartbeat делает `worker=unavailable` и `files=unavailable`, но сохраняет text/links; KESL broker/S3 down или stale KESL database выключает только `files`; DNS down — только `links`; Redis down прогревается из PostgreSQL и не выключает core. Каждый POST повторно проверяет требуемую capability и остаётся источником correctness; api-backend может кэшировать health snapshot не дольше 5 с только для fast-fail. Через private nginx ВМ2 endpoint доступен как exact `GET /internal/safety/status`, который проксируется в `/health/ready`; прямой `/health/ready` остаётся локальным container health. Private alias ограничен SG/source allow-list, не требует service token и не раскрывает credentials/hostnames.
В MOCK health всегда явно возвращает `processing_mode=mock`, `mock_policy.text=allow|deny`, `mock_policy.file=allow|deny` и `status=degraded`, даже если forced responses доступны. Normal pipeline dependencies показываются как `bypassed`, не `ok`. Active alert не закрывается до возврата в `standard`.
@@ -793,7 +792,7 @@ Core `HTTP 503 status=not_ready` используется только для in
### 15.1. Seed active config в `message_safety`
```yaml
schema_version: 1
schema_version: 2
rules_bundle_ref: rules-2026-01-01
detector_manifest_ref: detector-2026-08-03
task:
@@ -820,7 +819,7 @@ link:
url_max_length: 2048
dns_lookup_timeout_sec: 1
pipeline_timeout_sec: 2
clamav:
kesl:
scan_timeout_sec: 45
max_signature_age_hours: 240
file_policy:
@@ -850,8 +849,7 @@ activation. Image-only rollback после активации несовмест
APP_ENV=production-like
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
MESSAGE_SAFETY_DNS_RESOLVERS=<VPC-resolver-IP>
MESSAGE_SAFETY_CLAMAV_HOST=clamd
MESSAGE_SAFETY_CLAMAV_PORT=3310
MESSAGE_SAFETY_KESL_SOCKET=/run/han-kesl/scan.sock
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
@@ -875,7 +873,7 @@ Availability SLO для MVP не утверждается. До cutover обяз
| File | sustained 2 checks/s, 5 worker slots | среднее processing ≤2.5 с, p95 final ≤60 с, p99/public wait ≤300 с |
| Pending/backpressure | до 100 active tasks | queue age p95 ≤5 с; при 100 новый file POST получает retryable `503` без task |
File corpus: 70% JPEG/PNG/WebP до 1 MiB, 20% PDF/HEIC до 2 MiB, 10% boundary samples до 5 MiB; включает clean, EICAR, malformed, encrypted и parser-limit cases. Если 5 slots не подтверждают 2 file/s и среднее ≤2.5 с, Safety Service Owner до cutover увеличивает slots/CPU/scan lanes и повторяет тест.
File corpus: 70% JPEG/PNG/WebP до 1 MiB, 20% PDF/HEIC до 2 MiB, 10% boundary samples до 5 MiB; включает clean, EICAR, malformed, encrypted и parser-limit cases. Если 5 slots не подтверждают 2 file/s и среднее ≤2.5 с, Safety Service Owner до cutover корректирует slots/CPU/broker concurrency в пределах подтверждённой KESL capacity и повторяет target-VM test.
Превышение text/file token bucket → `429 rate_limit_exceeded` + `Retry-After`. Pending считается authoritative запросом PostgreSQL; Redis используется как быстрый счётчик. При Redis outage rate limiter использует conservative in-process limits, а pending gate остаётся в PostgreSQL.
@@ -920,7 +918,7 @@ Runtime schema сравнивается с committed artifact contract test. Ter
- PostgreSQL claim/lease/fencing concurrency/recovery; Redis cache loss;
- pending=100 backpressure, token buckets и 5 worker slots;
- S3 versioned read, ETag/version changed/missing/timeout;
- ClamAV clean, EICAR, FOUND, timeout, daemon down, stale signatures;
- KESL broker clean, EICAR/infected, timeout, scanner down, stale database и неизвестный формат `kesl-control`;
- full SHA-256 and size mismatch;
- duplicate concurrent request and worker retry;
- dependency recovery without changed final verdict.
@@ -972,10 +970,10 @@ Runtime schema сравнивается с committed artifact contract test. Ter
### 18.2. Definition of Done
- production v2 `200/202/403` contract реализован без stub divergence;
- MOCK 2×2 text/file matrix даёт только sync `200/403`; тест доказывает отсутствие calls к rules/DNS/S3/ClamAV/cache/workers и сохранение auth/DTO/idempotency/audit/rate limits;
- MOCK 2×2 text/file matrix даёт только sync `200/403`; тест доказывает отсутствие calls к rules/DNS/S3/KESL broker/cache/workers и сохранение auth/DTO/idempotency/audit/rate limits;
- text-rules cache, text rules, link cache и URL pipeline покрывают threat model и false-positive corpus;
- fetch/render/redirect follow к пользовательским URL отсутствует по design и тестам;
- files проходят metadata, authoritative SHA-256, format detector и ClamAV;
- files проходят metadata, authoritative SHA-256, format detector и KESL broker;
- final task verdict sticky и durable;
- idempotency/concurrency/recovery доказаны тестами;
- PG schema `message_safety`, Redis Safety и S3 version-specific read работают по least privilege;
@@ -985,13 +983,13 @@ Runtime schema сравнивается с committed artifact contract test. Ter
- mock mode сохраняется internal, не раскрывается public; forced file allow получает `scan_status=bypassed`, не `clean`;
- public deny использует `safety.chat.blocked` и не раскрывает internal rule;
- blocked/failed content не попадает в Bitrix и не promote-ится;
- health/readiness и capability-specific POST отражают PG/Redis/S3/workers/ClamAV/DNS/rules;
- health/readiness и capability-specific POST отражают PG/Redis/S3/workers/KESL broker/database age/DNS/rules;
- performance acceptance §15.4 пройден на target sizing;
- immutable/versioned S3 negative tests и conditional promote пройдены;
- OpenAPI 3.1 и runtime parity зелёные;
- logs/metrics/traces не содержат содержимое сообщений, файлов и secrets;
- hardened containers запускаются без public port;
- runbook описывает signature update, stale signatures, AV outage, retry и rollback.
- runbook описывает hourly KESL update, stale database, broker/KESL outage, retry и rollback.
- `deploy` может выполнить все пять exact mode commands, но не читать/писать mode config и не получить Docker/general sudo; helper rollback, persistent MOCK alert и возврат в standard испытаны.
## 19. Размещение проекта и вынос на отдельную ВМ
@@ -1016,7 +1014,7 @@ Stub в `codebase/backend/message-safety/` не является целевой
### 19.2. Целевая топология
Message Safety и `bitrix-sync` выносятся **за пределы ВМ HAN_CHAT (ВМ1)** на самостоятельную **ВМ2 Processing** в той же private network/VPC. Цель — снять с ВМ1 ClamAV/file scan, DNS classification, S3 streaming, CRM sync и входящий CRM webhook. Message Safety остаётся private API; Bitrix24 обращается напрямую к отдельному public host nginx ВМ2.
Message Safety и `bitrix-sync` выносятся **за пределы ВМ HAN_CHAT (ВМ1)** на самостоятельную **ВМ2 Processing** в той же private network/VPC. Цель — снять с ВМ1 file scan, DNS classification, S3 streaming, CRM sync и входящий CRM webhook. Host KESL 12.4 standalone и broker работают на VM2 вне Compose. Message Safety остаётся private API; Bitrix24 обращается напрямую к отдельному public host nginx ВМ2.
```mermaid
flowchart LR
@@ -1030,7 +1028,7 @@ flowchart LR
gateway[VM2_nginx_public_private]
safety[message-safety_api_worker]
sync[bitrix-sync]
clamav[clamd]
kesl[KESL_12_4_and_broker]
redis2[Redis_Safety]
collector[otel-collector]
end
@@ -1046,13 +1044,13 @@ flowchart LR
safety --> redis2
safety --> managedPg
safety --> s3q
safety --> clamav
safety -->|"Unix socket"| kesl
sync --> managedPg
sync --> bitrix
collector --> signoz
```
На ВМ2 один root Compose включает собственный nginx с public webhook `443` и private `8443`, `message-safety` API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety и local OTEL Collector. Managed PostgreSQL и S3 остаются вне VM. Внешнего URL reputation provider нет.
На ВМ2 один root Compose включает собственный nginx с public webhook `443` и private `8443`, `message-safety` API/worker, `bitrix-sync`, Redis Safety и local OTEL Collector. `clamd`/`freshclam` в Compose отсутствуют; host KESL 12.4 standalone и root-owned broker управляются отдельно. Managed PostgreSQL и S3 остаются вне VM. Внешнего URL reputation provider нет.
### 19.3. Исключение из docker compose HAN_CHAT
@@ -1079,7 +1077,7 @@ flowchart LR
| `arch-03` | один Compose ВМ1 | один root Compose на каждой VM |
| `arch-04` | local Docker URLs | remote HTTPS URL ВМ1; service-specific env/secrets ВМ2 |
| CI/CD | build из backend tree | отдельный pipeline artifact `codebase/message-safety` |
| Security groups | intra-docker network | internet→nginx ВМ2:80/443 с exact route policy; ВМ1→ВМ2:8443; ВМ2→PG/S3/DNS/Bitrix/SigNoz/signature CDN по назначению |
| Security groups | intra-docker network | internet→nginx ВМ2:80/443 с exact route policy; ВМ1→ВМ2:8443; containers→PG/S3/DNS/Bitrix/SigNoz, host KESL→approved update sources по назначению |
Схема **`han_app` не получает новых таблиц** для remote safety: orchestration и recovery по-прежнему владеет `api-backend`. Меняется только сетевой адрес internal API и deployment boundary.
@@ -1087,7 +1085,7 @@ flowchart LR
- отдельный public IP/host nginx ВМ2 допускает только ACME/redirect policy на `80` и два exact CRM webhook на `443`; public Safety, generic `/internal/*`, admin и health запрещены;
- inbound: private TCP 8443 только с SG ВМ1/ops; server-auth TLS internal CA + service token;
- outbound по container identity: worker→PostgreSQL/S3/DNS; `freshclam`→signature CDN; `bitrix-sync`→Bitrix24; collector→SigNoz;
- outbound по identity: worker→PostgreSQL/S3/DNS; `bitrix-sync`→Bitrix24; collector→SigNoz; host KESL→approved update sources по операторскому runbook;
- capability health Safety доступен api-backend и ops как exact `/internal/safety/status` на private `8443`; container `/health/live` и `/health/ready` наружу не публикуются;
- при недоступности ВМ2 `api-backend` возвращает `503 dependency_unavailable`, не отправляет сообщения в Bitrix.
@@ -1102,7 +1100,7 @@ flowchart LR
3. **Text/rules** — normalization, versioned bundle/schema, monitor corpus и hard active-content deny.
4. **URL policy** — parser/IDNA/IP/DNS, cache split, NXDOMAIN monitor и запрет fetch.
5. **File/S3** — immutable version precondition, authoritative checksum, detector manifest и hard matrix.
6. **ClamAV** — clamd streaming, freshclam activate/rollback/signature age и EICAR tests.
6. **KESL broker** — host KESL 12.4 standalone, root-owned fail-closed Unix-socket broker, hourly update/database age и EICAR tests; формат `kesl-control` и throughput проверяются на target VM.
7. **api-backend integration** — v2 adapter, Location polling, M8/company replica/mnemonic, conditional promote.
8. **VM2/observability/MOCK** — root Compose, internal nginx/TLS, egress, local collector, root-owned five-command mode helper, persistent alert и dashboards.
9. **Acceptance** — contract/security/failure/load tests §1518 и S3 negative gate.
@@ -8,7 +8,7 @@
Документ задаёт, **что агент ВМ2 реализует в Compose, коде, тестах и алертах этой машины**.
ВМ2 владеет nginx (public CRM webhook + private `8443`), `message-safety`, `bitrix-sync`, `clamd`/`freshclam`, Redis Safety и локальным Collector. Guest bootstrap, Keycloak, `api-backend` и `bitrix-local-app` живут на ВМ1. ВМ2 не использует Docker hostname collector ВМ1.
ВМ2 владеет nginx (public CRM webhook + private `8443`), `message-safety`, `bitrix-sync`, Redis Safety и локальным Collector в Compose, а также host KESL 12.4 standalone и root-owned fail-closed broker. Guest bootstrap, Keycloak, `api-backend` и `bitrix-local-app` живут на ВМ1. ВМ2 не использует Docker hostname collector ВМ1.
Агент ВМ2 не добавляет scrape, дашборды и алерты сервисов ВМ1.
@@ -22,7 +22,7 @@
| Redis Safety | `redis` |
| local Collector | `otel-collector` |
`clamd` / `freshclam` покрываются host/container metrics и сигналами Safety (signature age, scan lanes), отдельное `service.name` в реестр arch-07 не добавляется без явного решения.
Host KESL и broker покрываются host/systemd metrics и сигналами Safety (database age, broker availability/latency, scan concurrency); отдельное `service.name` в реестр arch-07 не добавляется без явного решения.
Различать экземпляр от ВМ1 через `host.name` / `service.instance.id`.
@@ -44,7 +44,7 @@ Scrape targets ВМ2 (кроме самого Collector): Redis Safety exporter,
- server spans API с route template; poll `202` не маскирует финальный verdict;
- worker spans claim/process/finalize и span links на origin `request_id` / `trace_id` caller;
- child spans: PostgreSQL Safety schema, Redis Safety, S3 quarantine, ClamAV/DNS classification без file content и raw URL;
- child spans: PostgreSQL Safety schema, Redis Safety, S3 quarantine, KESL broker/DNS classification без file content, socket payload/path и raw URL;
- `/health/live` исключить из traces; capability/readiness — metrics и sampled logs;
- stub mode (`400`, non-sticky) маркируется как `stub`; production SLO Safety на stub недостоверен.
@@ -67,7 +67,7 @@ Exporter и ACL — arch-07 §8.28.3. Клиентские pool/queue metrics
### 4.5. Host/Docker ВМ2
CPU, memory, disk, network, restarts/OOM, Docker daemon, clock sync — arch-07 §8.5. Отдельно контролировать `clamd` restarts, signature age и scan lane saturation.
CPU, memory, disk, network, restarts/OOM, Docker daemon, clock sync — arch-07 §8.5. Отдельно контролировать host KESL/broker unit state, права `/run/han-kesl/scan.sock`, database age, hourly update result и broker/KESL saturation.
## 5. Метрики бизнес-потоков ВМ2
@@ -105,7 +105,7 @@ UUID/user/session/dialog/task/message id не labels.
В SigNoz, с filter `host.name` / environment ВМ2:
1. **nginx ingress ВМ2**: RPS, 4xx/5xx, upstream latency/status, TLS, cache, **CRM webhook** (accept/reject by source IP, method, rate limit). Без guest API/WS — это ВМ1.
2. **message-safety**: capabilities, verdicts, `202` poll, PG queue age/leases/fencing, ClamAV/signature age, file/link cache и DNS dependency. Stub явно маркируется.
2. **message-safety**: capabilities, verdicts, `202` poll, PG queue age/leases/fencing, KESL broker/database age, file/link cache и DNS dependency. `scanner_engine=kesl`; `signatures_version` представлен как hash KESL version + database date. Stub явно маркируется.
3. **bitrix-sync**: mode/readiness, queue depth/oldest age, workflow/command transitions, CRM batch latency/subcommand outcome, limiter/throttle, retry/DLQ, webhook/reconciliation lag, mapping invariants и business-alert SLA. До cutover — `sync_disabled`.
4. **Redis Safety**: memory/evictions/AOF/latency/clients/keyspace.
@@ -127,7 +127,7 @@ UUID/user/session/dialog/task/message id не labels.
- Redis Safety unavailable, AOF error или sustained evictions;
- Collector ВМ2 exporter queue >80%, dropped/refused telemetry >0 sustained;
- TLS expiry public webhook host и private `8443` <14 дней warning, <7 дней page;
- disk/OOM/restart loop ВМ2; `clamd` restart loop / stale signatures по runbook module-05.
- disk/OOM/restart loop ВМ2; KESL/broker unit down, hourly update failure или stale database по runbook module-05.
### Ticket/warning
@@ -151,7 +151,7 @@ Optional profile `observability-local` на ВМ2 по умолчанию вык
### Высокая latency сообщения (hop ВМ2)
1. Найти task/span по `request_id` caller.
2. Разделить Safety API, worker lease, ClamAV/DNS, S3 quarantine, sync queue.
2. Разделить Safety API, worker lease, KESL broker/DNS, S3 quarantine, sync queue.
3. Проверить circuit, queue age, PG leases/fencing и Redis Safety.
4. Не повторять ambiguous scan/send без исходного idempotency key.
5. Следовать runbook [`module-05-message-safety.md`](module-05-message-safety.md) / [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md).
@@ -171,7 +171,7 @@ Optional profile `observability-local` на ВМ2 по умолчанию вык
- Collector ВМ2 validate + up в root Compose; hostname collector ВМ1 не используется;
- инструментированы `message-safety` API/worker и `bitrix-sync`;
- nginx JSON parsing, webhook reject metric и private `8443` correlation проверены;
- Redis Safety, host/Collector, ClamAV signature age metrics доступны;
- Redis Safety, host/Collector, KESL broker/database age/hourly update metrics доступны;
- дашборды и alerts §6–§7 provisioned либо явно TBD до SigNoz packaging;
- stub/MOCK маркируются; production Safety SLO не объявляется на stub;
- до sync cutover dashboard `sync_disabled`;
@@ -8,7 +8,7 @@
## 1. Границы
ВМ2 владеет nginx public `80/443` (только exact CRM webhook) и private `8443` (Message Safety), `message-safety` API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety, Collector. Guest API, Keycloak, SPA и SMS callback здесь не разворачиваются.
ВМ2 владеет nginx public `80/443` (только exact CRM webhook) и private `8443` (Message Safety), `message-safety` API/worker, `bitrix-sync`, Redis Safety и Collector в Compose. KESL 12.4 standalone и root-owned fail-closed broker работают на host VM2 вне Compose; broker слушает `/run/han-kesl/scan.sock`. Guest API, Keycloak, SPA и SMS callback здесь не разворачиваются.
`<BACKEND_ROOT>` / `<BACKEND_REPO_URL>` — репозиторий ВМ2. Public ACME host — `<PROCESSING_PUBLIC_HOST>`. Private DNS `processing.internal` не публикуется.
@@ -23,15 +23,15 @@
- 100 pending принимаются; 101-й file POST — retryable `503` без новой task;
- RPS overflow — `429 + Retry-After`;
- long Safety poll не блокирует WS/read API ВМ1;
- если gate не пройден — увеличить slots/CPU/clamd scan lanes; production traffic не открывать.
- если gate не пройден — корректировать slots/CPU/broker concurrency в пределах подтверждённой KESL capacity; production traffic не открывать.
Monthly availability SLO Safety в MVP не задаётся. Scale-out/ВМ3: sustained CPU/RAM >70%, queue age >30 с, провал performance gates, contention `bitrix-sync` или независимый release cadence. Workers масштабируются первыми по queue depth, `clamd` — scan lanes.
Monthly availability SLO Safety в MVP не задаётся. Scale-out/ВМ3: sustained CPU/RAM >70%, queue age >30 с, провал performance gates, contention `bitrix-sync` или независимый release cadence. Workers масштабируются первыми по queue depth; предел задаёт проверенная throughput host KESL/broker.
## 3. Hardening и egress
Arch-10 §5 / arch-06. Public Docker ports — `80,443`. Private `8443` не internet SG.
Default-deny egress. Bootstrap window для registry/OS, затем закрыть. Оставить: S3, approved Bitrix portal (`bitrix-sync`), signature CDN (`freshclam`), DNS/NTP, SigNoz `4317`, PG. Постоянный open egress запрещён.
Default-deny egress. Bootstrap window для registry/OS, затем закрыть. Оставить: S3, approved Bitrix portal (`bitrix-sync`), DNS/NTP, SigNoz `4317`, PG; для host KESL — только approved update sources по операторскому KESL runbook. Постоянный open egress запрещён.
Отдельный IAM principal Selectel: только VM2 secret names.
@@ -57,11 +57,11 @@ Checkout exact SHA. Структура: root Compose, `nginx`, Safety, `bitrix-s
## 6. Root Compose ВМ2
Сервисы: public/private nginx, `message-safety-api`, `message-safety-worker`, `clamd`, `freshclam`, `bitrix-sync`, Redis Safety, local `otel-collector`.
Сервисы Compose: public/private nginx, `message-safety-api`, `message-safety-worker`, `bitrix-sync`, Redis Safety, local `otel-collector`. `clamd`/`freshclam` удалены.
Networks: `public` (только nginx webhook/ACME), `backend`, `egress` (freshclam, bitrix-sync, Safety worker → S3/PG/DNS, collector → SigNoz), `observability`. Safety API/clamd/Redis без общего internet egress.
Networks: `public` (только nginx webhook/ACME), `backend`, `egress` (`bitrix-sync`, Safety worker → S3/PG/DNS, collector → SigNoz), `observability`. Safety API/Redis без общего internet egress. Worker получает только read/write bind Unix socket `/run/han-kesl/scan.sock`, а не доступ к host command execution.
Volumes: Redis Safety data (rebuildable), ClamAV signatures, ACME public, `otel-queue` + init. Frontend-static нет. Published: nginx 80/443; `8443` только SG ВМ1/ops.
Volumes: Redis Safety data (rebuildable), ACME public, `otel-queue` + init. KESL database/runtime не являются Compose volumes. Frontend-static нет. Published: nginx 80/443; `8443` только SG ВМ1/ops.
Compose gate — arch-10 применительно к этому Compose.
@@ -108,9 +108,11 @@ Rollback: закрыть webhook или `503`; `BITRIX_SYNC_ENABLED=false`; ВМ
## 10. Ordered startup ВМ2
1. Redis Safety, `otel-queue-init`, Collector;
2. `clamd`/`freshclam`, Safety API/worker, `bitrix-sync`;
3. nginx последним: оба TLS, exact webhook, capability health, signature age, negative ingress/egress.
1. Выполнить operator KESL runbook: проверить KESL 12.4 standalone, database date и ежечасное обновление.
2. `enable/start` broker socket/service; проверить status и owner/group/mode `/run/han-kesl/scan.sock`.
3. Redis Safety, `otel-queue-init`, Collector.
4. Safety API/worker и `bitrix-sync`; files остаётся fail-closed до успешной проверки broker.
5. nginx последним: оба TLS, exact webhook, capability health, KESL database age, negative ingress/egress.
До enablement `bitrix-sync``sync_disabled`. Safety v2 capability `text|links|files|worker`. Redis Safety не core gate readiness.
@@ -139,7 +141,7 @@ Cutover gates: private TLS chain/SAN; Safety v2 PG migration и lease/fencing sm
Rollback ВМ2 не требует изменения nginx ВМ1. Caller rollback — runbook ВМ1.
## 14. Emergency MOCK и Freshclam
## 14. Emergency MOCK и KESL
`deploy` без root login:
@@ -153,11 +155,11 @@ sudo /usr/local/sbin/han-message-safety-mode standard
Helper `root:root 0755`; sudoers только этот executable. Config `root:han-message-safety 0640`, GID `10001`. Нет auto-expiry; incident не закрывать без `standard` и canary. File в MOCK — `scan_status=bypassed`, не `clean`. `deploy` не пишет Compose/Docker.
`freshclam`: controlled egress только к signature CDN. Seed `max_signature_age_hours=240`, schema max `720`. Stale/failed update выключает только `files` и alert. Новая база — integrity/EICAR, atomic activate; regression — предыдущая валидная база.
KESL обновляет базы на host ежечасно по отдельному operator KESL runbook. Seed `max_signature_age_hours=240`, schema max `720`. Stale/failed update выключает только `files` и создаёт alert; scanner error не становится allow. Broker является custom integration: формат/exit semantics `kesl-control --scan-file --action Inform` и throughput подтверждаются target-VM gates.
## 15. Rollback, ops, incidents ВМ2
Redis Safety restore — empty/clean, lazy cache ([`module-04-redis-vm2.md`](module-04-redis-vm2.md)). Routine: signature age, Safety queue, sync DLQ/webhook lag, Collector, disk, egress still deny.
Redis Safety restore — empty/clean, lazy cache ([`module-04-redis-vm2.md`](module-04-redis-vm2.md)). Routine: KESL version/database date и hourly update, broker/socket status, Safety queue, sync DLQ/webhook lag, Collector, disk, egress still deny.
Incident: Safety timeout — checkpoint, не новый task id; Bitrix down — circuit/DLQ, webhook `503`; MOCK page — вернуть standard; cert processing host и private CA.
@@ -171,7 +173,7 @@ Incident: Safety timeout — checkpoint, не новый task id; Bitrix down
- private `8443` fail-closed до cutover, затем только источники ВМ1;
- webhook allow-list и preflight `BITRIX_SYNC_ENABLED` согласованы;
- capability/load gates module-05;
- MOCK helper и Freshclam rehearsal;
- MOCK helper и KESL/broker rehearsal;
- observability ВМ2 + redaction (нет query/body/file content);
- cutover §13 не объявлен выполненным только документацией.
+1 -1
View File
@@ -82,7 +82,7 @@
## Каноническое размещение production-контуров
- **ВМ1 HAN Chat** — самостоятельная публичная точка входа приложения: nginx, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контур, Redis DB0/DB1 и локальный OTEL Collector.
- **ВМ2 Processing** — самостоятельная service VM с отдельным public webhook host, private Message Safety ingress и постоянным ограниченным egress: `message-safety`, `bitrix-sync`, `clamd`/`freshclam`, отдельный Redis Safety, nginx и локальный OTEL Collector.
- **ВМ2 Processing** — самостоятельная service VM с отдельным public webhook host и private Message Safety ingress: в root Compose работают `message-safety`, `bitrix-sync`, отдельный Redis Safety, nginx и локальный OTEL Collector; на host работают KESL 12.4 standalone и root-owned fail-closed broker с Unix socket `/run/han-kesl/scan.sock`. `clamd`/`freshclam` в Compose отсутствуют.
- На каждой VM действует один root Compose project и отдельный root-owned systemd deployment unit. «Единый Compose» означает один проект **на VM**, а не один общий project через несколько хостов.
- Bitrix24 вызывает CRM webhook напрямую на nginx ВМ2; ВМ1 в route не участвует. ВМ1 вызывает только Message Safety по private HTTPS.
- При росте нагрузки `bitrix-sync` может быть перенесён на ВМ3 без изменения API и границ схем PostgreSQL.
+4 -3
View File
@@ -63,7 +63,7 @@ HAN Chat - приложение для мигрантов, где стартов
Production-like backend разделён на два контура в одной private network/VPC:
- **ВМ1 HAN Chat**: edge `nginx`, `api-backend`, `keycloak`, `sms-service`/worker, `bitrix-local-app`, Redis DB0/DB1 и локальный `otel-collector`;
- **ВМ2 Processing**: собственный public/private `nginx`, `message-safety` API/worker, `clamd`/`freshclam`, `bitrix-sync`, отдельный Redis Safety и локальный `otel-collector`;
- **ВМ2 Processing**: в root Compose — собственный public/private `nginx`, `message-safety` API/worker, `bitrix-sync`, отдельный Redis Safety и локальный `otel-collector`; на host — KESL 12.4 standalone и root-owned fail-closed broker с `/run/han-kesl/scan.sock`;
- каждая VM имеет один root Compose project и отдельный root-owned systemd deployment unit;
- ВМ1 и ВМ2 имеют независимые public DNS/TLS ingress на своих nginx; ВМ2 публикует только exact CRM webhook;
- ВМ1 вызывает ВМ2 по private HTTPS с проверкой internal CA, service token, cloud SG и host firewall;
@@ -97,7 +97,8 @@ flowchart LR
api -->|"HTTPS 8443 + service token"| privateGateway[VM2_PrivateListener]
privateGateway --> safety[MessageSafetyApi]
safety --> worker[SafetyWorker]
worker --> clamd[Clamd]
worker -->|"Unix socket /run/han-kesl/scan.sock"| keslBroker[KESLBroker]
keslBroker --> hostKesl[HostKESL12_4]
worker --> s3q[S3Quarantine]
worker --> pg[ManagedPostgreSQL]
sync --> pg
@@ -590,7 +591,7 @@ App DB — **локальный кэш** для UI. Двусторонний syn
Минимальный целевой real-SMS контур разделён на два stack:
- ВМ1: `nginx`, `api-backend`, `keycloak`, `sms-service`/worker, `bitrix-local-app`, Redis DB0/DB1, `otel-collector`;
- ВМ2: nginx с public webhook/private internal server blocks, `message-safety` API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety, `otel-collector`.
- ВМ2: nginx с public webhook/private internal server blocks, `message-safety` API/worker, `bitrix-sync`, Redis Safety, `otel-collector` в Compose; KESL 12.4 standalone и root-owned broker на host. `clamd`/`freshclam` удалены из Compose.
До SMS rollout сервисы SMS могут отсутствовать, но Keycloak обязан оставаться в mock mode.
@@ -64,7 +64,6 @@ processing/
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
```
@@ -93,7 +92,7 @@ volumes:
redis-data:
```
Root Compose ВМ2 включает собственный nginx с public/private server blocks, Message Safety API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety и локальный OTEL Collector. Секреты, сети и volumes двух projects не общие.
Root Compose ВМ2 включает собственный nginx с public/private server blocks, Message Safety API/worker, `bitrix-sync`, Redis Safety и локальный OTEL Collector. `clamd`/`freshclam` удалены из Compose; KESL 12.4 standalone и root-owned fail-closed broker работают на host VM2. Секреты, сети и volumes двух projects не общие.
### Правила для сервисных compose-файлов
@@ -254,6 +253,7 @@ Python FastAPI backend.
- 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;
- worker монтирует только Unix socket `/run/han-kesl/scan.sock` root-owned broker; KESL и `kesl-control` остаются на host, TCP scanner port отсутствует;
- API container получает read-only
`/etc/han-chat/message-safety-mode.env` с host contract
`root:han-message-safety 0640` и GID `10001`; менять его и перезапускать
@@ -262,29 +262,26 @@ Python FastAPI backend.
- экспортирует 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
### Host KESL и broker на ВМ2
`clamd` и `freshclam` используют один immutable image digest, но разные
security-профили:
KESL 12.4 работает standalone на host и не является Compose service. Отдельный
root-owned custom broker слушает только `/run/han-kesl/scan.sock` и вызывает
фиксированный `kesl-control --scan-file --action Inform`.
- оба запускаются через 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 дней), то есть строже предельного значения.
- socket монтируется только в Message Safety worker и доступен выделенной
группе; worker не получает host binary, shell, Docker socket или host root;
- broker принимает только bounded scan request и fail-closed сопоставляет
`clean → allow`, `infected → deny`, а timeout, stale database, неизвестный
output/exit code и недоступность KESL → retry/`503`;
- `scanner_engine=kesl`; `signatures_version` — hash KESL version + database
date;
- KESL обновляет database ежечасно на host; update egress не подключает
контейнеры к internet;
- schema допускает `max_signature_age_hours` до `720`, seed — `240`.
Смена digest ClamAV требует повторной проверки entrypoint, UID/GID, writable
paths, `clamd` health и фактического обновления signatures. Нельзя менять
только tag/digest, считая security contract image неизменным.
Формат результата `kesl-control`, socket permissions, cleanup и throughput
этой custom integration подтверждаются gates на target VM2 с фактическим KESL
12.4; repository-only/Compose health не считается достаточным evidence.
### bitrix-sync
@@ -400,9 +397,9 @@ Identity provider. **Обязателен** в compose-контуре с пер
- ВМ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.
- ВМ2 `backend`: nginx, Safety API/worker, `bitrix-sync` и Redis Safety; broker доступен worker только через host Unix socket.
- ВМ1 `egress` подключается только к процессам с назначением: `sms-worker` → i-Digital Direct; Keycloak → SmartCaptcha только при включённом feature flag; `api-backend` → S3 и private PG, а вызов Safety идёт к `processing.internal:8443`; local collector → private SigNoz. `sms-service` без совмещённого worker, `bitrix-local-app` и Redis не получают общий internet egress.
- ВМ2 `egress` подключается только к процессам с назначением: `freshclam` → signature CDN; `bitrix-sync` → утверждённый Bitrix portal; Safety worker → S3/PG/DNS; collector → private SigNoz. Общего internet egress у Safety API/clamd/Redis нет.
- ВМ2 `egress` подключается только к процессам с назначением: `bitrix-sync` → утверждённый Bitrix portal; Safety worker → S3/PG/DNS; collector → private SigNoz. Общего internet egress у Safety API/Redis нет; KESL update egress задаётся отдельно на host.
- `observability` существует отдельно на каждой VM и ведёт в её local collector.
Базы данных, Redis, OTLP receivers и internal service ports не публикуются. Cross-host calls идут через private network, точные SG и TLS.
@@ -412,8 +409,8 @@ Identity provider. **Обязателен** в compose-контуре с пер
Минимальные persistent volumes:
- ВМ1: Redis DB0/DB1 data, local OTEL queue;
- ВМ2: Redis Safety data (rebuildable), ClamAV signatures и runtime,
internal TLS secrets, local OTEL queue.
- ВМ2: Redis Safety data (rebuildable), internal TLS secrets, local OTEL queue.
KESL database/runtime и `/run/han-kesl` принадлежат host, не Compose volumes.
Public TLS и ACME на обеих VM — не named volumes. Root-only ACME state остаётся
на host в `/etc/letsencrypt`; root hook атомарно копирует только нужные
@@ -561,7 +558,7 @@ WAF не заменяет обязательные лимиты, валидац
- `nginx`: на веб-домене — `308` с `:80` на HTTPS; на API-домене (если выделен) — `:80` не слушает; `:443` — успешная TLS handshake и ожидаемый route response;
- `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;
- `message-safety`: `/health/ready` возвращает process/core status и capability map `text|links|files|worker`; KESL broker/stale database/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;
@@ -591,8 +588,8 @@ host-side secrets/TLS materialized, controlled migrations и seed заверше
ВМ2 запускается в порядке:
1. `redis-safety`, `otel-queue-init`, затем local `otel-collector`;
2. `freshclam`, затем `clamd` до состояния healthy;
1. operator KESL runbook: KESL 12.4/database update, затем enable/start broker socket и проверка status/permissions;
2. `redis-safety`, `otel-queue-init`, затем local `otel-collector`;
3. Message Safety API/worker и `bitrix-sync`;
4. nginx — последним, после успешного config test;
5. private HTTPS ВМ1→ВМ2 и capability health проверяются до cutover.
+3 -4
View File
@@ -199,7 +199,7 @@ worker.lease_seconds=90
## Service-owned настройки `message-safety`
Runtime policy хранится в версионированной `message_safety.config_versions`, а не в `.env` и не в `han_app.app_settings`. Сюда входят task lease/deadline/attempts, internal rate/pending limits, retention/cache TTL, URL/DNS pipeline limits, ClamAV policy timeout/signature age и enabled file MIME/size policy. Для ClamAV schema допускает возраст сигнатур не более `720` часов (30 дней), seed `max_signature_age_hours` равен `240` часам (10 дней). Полный schema/seed/activation contract — module-05 §10.1 и §15.
Runtime policy хранится в версионированной `message_safety.config_versions`, а не в `.env` и не в `han_app.app_settings`. Сюда входят task lease/deadline/attempts, internal rate/pending limits, retention/cache TTL, URL/DNS pipeline limits, KESL broker timeout/database age и enabled file MIME/size policy. Schema допускает возраст базы KESL не более `720` часов (30 дней), seed `max_signature_age_hours` равен `240` часам (10 дней). Полный schema/seed/activation contract — module-05 §10.1 и §15.
Seed, JSON Schema и referenced artifacts входят в immutable Message Safety
image. Их изменение требует одновременно нового pinned image digest и новой
@@ -211,7 +211,7 @@ active row на месте. Rollback выполняется новой config ve
`han_app.app_settings:chat.attachments.*` остаётся бизнес-настройкой api-backend. Message Safety не получает cross-schema read к `han_app`; файл допускается только при пересечении business allow-list, active safety policy и immutable detector manifest. Active policy может сузить manifest, но не добавить parser и не увеличить hard limit.
В env Message Safety остаются только bootstrap/topology/capacity (`APP_ENV`, worker concurrency, DNS resolver, ClamAV/S3/OTLP endpoints); credentials доставляются secret files. Rules/detector versions вычисляются/проверяются по immutable artifacts. Emergency MOCK остаётся в отдельном read-only bind file `root:han-message-safety 0640` с dedicated GID контейнера и намеренно не переносится в БД.
В env Message Safety остаются только bootstrap/topology/capacity (`APP_ENV`, worker concurrency, DNS resolver, Unix socket KESL broker, S3/OTLP endpoints); credentials доставляются secret files. Rules/detector versions вычисляются/проверяются по immutable artifacts. `scanner_engine=kesl`; `signatures_version` вычисляется как hash KESL version + database date. Emergency MOCK остаётся в отдельном read-only bind file `root:han-message-safety 0640` с dedicated GID контейнера и намеренно не переносится в БД.
---
@@ -379,8 +379,7 @@ HAN_APP_SAFETY_RECOVERY_MAX_SEC=1200
# /etc/han-chat/message-safety-mode.env и меняется approved helper-ом.
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
MESSAGE_SAFETY_DNS_RESOLVERS=<VPC-resolver-IP>
MESSAGE_SAFETY_CLAMAV_HOST=clamd
MESSAGE_SAFETY_CLAMAV_PORT=3310
MESSAGE_SAFETY_KESL_SOCKET=/run/han-kesl/scan.sock
# =============================================================================
# Frontend (nginx)
@@ -62,7 +62,7 @@ SigNoz относится к этому классу, если его UI, OTLP
- public `443` разрешает только exact `/bitrix/sync/webhook/contact` и `/bitrix/sync/webhook/alert`; остальные paths закрыты;
- private ingress `8443/tcp` разрешён только от security group ВМ1 и утверждённого ops path для Message Safety/internal API;
- ни один public запрос ВМ2 не проходит через nginx ВМ1;
- `freshclam` имеет egress только к утверждённым источникам сигнатур;
- host KESL имеет egress только к утверждённым источникам обновления из operator KESL runbook; Message Safety и broker не получают общий internet egress;
- `bitrix-sync` имеет HTTPS egress только к утверждённому порталу Bitrix24;
- Safety worker имеет доступ только к managed PostgreSQL, S3-quarantine и доверенному DNS resolver;
- локальный OTEL Collector имеет private egress к SigNoz;
@@ -513,23 +513,28 @@ certificate/key проверяются preflight. Успешный hook обяз
при ненулевом exit code. Иначе Certbot/оркестратор может пометить успешный
renewal как hook error.
### Daemon и updater как разные security-профили
### Host KESL и root-owned broker
Если один vendor image используется для daemon и updater, им задаются разные
сети, mounts и health semantics. Проверенный паттерн ClamAV:
KESL 12.4 standalone и custom integration broker работают на host VM2, вне
Compose. Граница между non-root Message Safety worker и privileged host:
- `clamd` не имеет signature-CDN egress, читает signatures read-only и имеет
healthcheck реального daemon socket;
- `freshclam` один получает ограниченный egress и write к signatures;
- оба используют vendor `init-unprivileged` и только выделенные writable
`/run/clamav`, `/var/log/clamav` и `/tmp`;
- updater запускается как постоянный foreground daemon, чтобы restart policy
не превращала успешный one-shot exit в download loop/rate limit;
- унаследованный healthcheck, проверяющий отсутствующий в updater-контейнере
daemon, отключается; updater контролируется по `Up`, restart count, логам и
возрасту сигнатур;
- security policy разрешает настроить порог возраста не выше `720` часов
(30 дней); production-like seed использует более строгие `240` часов.
- root-owned broker слушает только Unix socket `/run/han-kesl/scan.sock`;
TCP listener и Docker socket запрещены;
- socket доступен только выделенной группе worker; owner/group/mode
проверяются после каждого restart/reboot;
- worker не получает `kesl-control`, shell или произвольный host path;
- broker принимает bounded request, создаёт контролируемый временный файл,
вызывает только `kesl-control --scan-file --action Inform` и гарантированно
очищает временные данные;
- только однозначный `clean` допускает allow; `infected` даёт deny; timeout,
stale database, неизвестный output/exit code и недоступность scanner дают
retry/`503`;
- KESL обновляет database ежечасно; update egress принадлежит host KESL и
ограничен approved sources;
- policy допускает `max_signature_age_hours` не выше `720`, seed — `240`.
Формат `kesl-control`, socket permissions, cleanup и throughput этой custom
integration подтверждаются gates на target VM2 с фактическим KESL 12.4.
Ошибки `read-only file system` устраняются точечным writable mount. Запрещено
лечить их глобальным `read_only: false`, root, `privileged` или broad
@@ -638,8 +643,8 @@ Fail2ban обязателен для SSH, временно или постоян
содержимому, а не только по декларации Compose;
- runtime/migration/config-admin DB roles разделены, временные cross-schema
grants выданы и отозваны владельцем;
- healthcheck проверяет процесс, реально присутствующий в контейнере, а
updater freshness контролируется отдельным сигналом;
- healthcheck проверяет процесс, реально присутствующий в контейнере, а KESL
broker/database freshness контролируется отдельным host-сигналом;
- healthcheck-команда и все её binaries подтверждены внутри exact pinned
digest; отсутствие `curl`/`wget` не обнаруживается впервые в production;
- внутренние ports недоступны извне;
+16 -7
View File
@@ -39,7 +39,7 @@
- **Safety Service Owner**: API/data contract, capacity result и v2 cutover/rollback sign-off.
- **Rule Pack Owner**: rules bundle, corpus, monitor report и version release.
- **Product Owner**: mnemonic `safety.chat.blocked` и business acceptance chat flow.
- **Operations Owner**: VM2 alerts, ClamAV signatures, incident/reprovision/restore rehearsal.
- **Operations Owner**: VM2 alerts, host KESL/broker и database updates, incident/reprovision/restore rehearsal.
```text
<PUBLIC_HOST> например chat.example.ru
@@ -93,7 +93,7 @@ Sizing и load gates конкретной машины — профильный
| ВМ2 collector | private SigNoz | TCP 4317 | allow |
| ВМ2 workers | S3 endpoints | TCP 443 | allow |
| ВМ2 `bitrix-sync` | approved Bitrix portal | TCP 443 | allow |
| ВМ2 `freshclam` | approved signature CDN | TCP 443/80 по vendor manifest | allow |
| host KESL ВМ2 | approved update sources по operator KESL runbook | vendor-required destinations/ports | allow |
| ВМ2 | trusted DNS/NTP | UDP/TCP 53, UDP 123 | allow |
| internet | managed PG | any | deny |
| internet | ВМ2 | any кроме nginx 80/443 | deny ingress |
@@ -237,11 +237,20 @@ Host ВМ1 — `<PUBLIC_HOST>`, ВМ2 — `<PROCESSING_PUBLIC_HOST>`; private `8
## 10. Сквозной порядок startup и cutover
1. На ВМ2 unit поднимает Redis Safety и local Collector.
2. Затем `clamd`/`freshclam`, Safety API/worker и `bitrix-sync`.
3. Последним на ВМ2 — nginx public `80/443` и private `8443`.
4. На ВМ1Redis/Collector, API, SMS, Keycloak, local app, edge nginx.
5. Только private `MESSAGE_SAFETY_URL` ВМ1 переключается на ВМ2 после Safety gates. Public CRM webhook DNS/routes ВМ2 не требуют изменения ВМ1.
1. На ВМ2 оператор выполняет KESL runbook: KESL 12.4 standalone, database update/canary.
2. Затем enable/start root-owned broker socket/service и проверяет status/permissions `/run/han-kesl/scan.sock`.
3. Unit ВМ2 поднимает Redis Safety и local Collector, затем Safety API/worker и `bitrix-sync`.
4. Последним на ВМ2nginx public `80/443` и private `8443`.
5. На ВМ1 — Redis/Collector, API, SMS, Keycloak, local app, edge nginx.
6. Только private `MESSAGE_SAFETY_URL` ВМ1 переключается на ВМ2 после Safety gates. Public CRM webhook DNS/routes ВМ2 не требуют изменения ВМ1.
Broker — custom integration: до cutover target VM2 обязана подтвердить точный
формат/exit semantics `kesl-control --scan-file --action Inform`, socket
permissions/cleanup и throughput. Только `clean` допускает allow; `infected`
даёт deny; scanner error или stale database остаются retryable и завершаются
`503`, не allow. Runtime фиксирует `scanner_engine=kesl` и
`signatures_version=hash(KESL version + database date)`; KESL update выполняется
ежечасно.
Legacy single-VM `docker compose up` не является evidence готовности target ВМ2. Один `message_id` нельзя одновременно отправлять в v1 и v2. После cutover ВМ1 не содержит local Safety/Redis DB2.
+22 -12
View File
@@ -1,10 +1,10 @@
Логирование операций
1. env:
editor /etc/han/vm1.env
изменение Release_Version
+ две переменные:
HOST_NAME=devhanapp \ prodhanapp.ihan.ru.
HOST_NAME=prodhanapp.ihan.ru \devhanapp \
TELEMETRYGEN_IMAGE=ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:9461e2c7213219467e5f775ea8652d15c990afb4d1ca94e62344f2025a7587d1
2. Обновить образы:
@@ -25,7 +25,7 @@ tar --exclude=backend/.env `
-czf "vm1-backend-$Release.tar.gz" `
-C C:/Users/MI/Documents/Assistent/HAN_chat_specification/VM1_app/codebase backend
Get-FileHash "vm1-backend-$Release.tar.gz" -Algorithm SHA256
scp "vm1-backend-$Release.tar.gz" devVM1Deploy:/var/lib/han-deploy/incoming/
scp "vm1-backend-$Release.tar.gz" prodVM1Deploy:/var/lib/han-deploy/incoming/
```
RELEASE='1.0.1'
@@ -91,14 +91,14 @@ sudo apt purge -y dos2unix
scp `
C:/Users/MI/.ssh/devDeploy.pub `
C:/Users/MI/.ssh/devAdmin.pub `
devVM1Deploy:/var/lib/han-deploy/incoming/
C:/Users/MI/.ssh/prodDeploy.pub `
C:/Users/MI/.ssh/prodAdmin.pub `
prodVM1Deploy:/var/lib/han-deploy/incoming/
install -m 0600 -o root -g root /var/lib/han-deploy/incoming/devDeploy.pub /root/bootstrap/deploy.pub
install -m 0600 -o root -g root /var/lib/han-deploy/incoming/devAdmin.pub /root/bootstrap/admin.pub
install -m 0600 -o root -g root /var/lib/han-deploy/incoming/prodDeploy.pub /root/bootstrap/deploy.pub
install -m 0600 -o root -g root /var/lib/han-deploy/incoming/prodAdmin.pub /root/bootstrap/admin.pub
rm -f /var/lib/han-deploy/incoming/devDeploy.pub /var/lib/han-deploy/incoming/devAdmin.pub
rm -f /var/lib/han-deploy/incoming/prodDeploy.pub /var/lib/han-deploy/incoming/prodAdmin.pub
cd /opt/han-chat/current/backend
@@ -109,6 +109,12 @@ ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
HARDEN_SSH=true SKIP_APT_UPGRADE=true \
/opt/han-chat/current/backend/deployment/scripts/setup-vm.sh
export OTEL_REMOTE_ENDPOINT="$(awk -F= '$1=="OTEL_REMOTE_ENDPOINT"{print substr($0,index($0,"=")+1)}' /etc/han/vm1.env)"
export OTEL_REMOTE_TLS_INSECURE="$(awk -F= '$1=="OTEL_REMOTE_TLS_INSECURE"{print substr($0,index($0,"=")+1)}' /etc/han/vm1.env)"
export HOST_NAME="$(awk -F= '$1=="HOST_NAME"{print substr($0,index($0,"=")+1)}' /etc/han/vm1.env)"
export APP_ENV="$(awk -F= '$1=="APP_ENV"{print substr($0,index($0,"=")+1)}' /etc/han/vm1.env)"
export RELEASE_VERSION="$(awk -F= '$1=="RELEASE_VERSION"{print substr($0,index($0,"=")+1)}' /etc/han/vm1.env)"
printf '%s\n' "$OTEL_REMOTE_ENDPOINT"
/usr/local/bin/otelcol-contrib validate \
--config=/opt/han-chat/current/backend/deployment/observability/otel-host-collector.yaml
@@ -116,12 +122,16 @@ chmod 0755 /opt/han-chat/current/backend/scripts/validate-env
/opt/han-chat/current/backend/deployment/preflight.sh
systemctl restart han-stack@production.service
если ошибка systemctl status han-stack@production.service
если ошибка
journalctl -u han-stack@production.service --no-pager -n 80 --output=cat
### !!! (образы могут устареть в будущем)
docker pull cr.selcloud.ru/han-images/nginx@sha256:2cd7d32967b400c15bb6aaa18e97e99d429b2b6f2bfd54b965fc7d42a91a228e
docker pull cr.selcloud.ru/han-images/sms-service@sha256:404685c45b7391a78f95c014ce8fdff64a84ccd7ae5b280e7a65b524c109d898
systemctl start han-host-otel-collector@production.service
rm -f /root/bootstrap/deploy.pub /root/bootstrap/admin.pub
Canary test: CONFIG_FILE=/etc/han/vm1.env ./deployment/scripts/verify-observability.sh
Canary test:
chmod 0755 /opt/han-chat/current/backend/deployment/scripts/verify-observability.sh
CONFIG_FILE=/etc/han/vm1.env ./deployment/scripts/verify-observability.sh
rm -f /root/bootstrap/deploy.pub /root/bootstrap/admin.pub
+647
View File
@@ -0,0 +1,647 @@
# Ставим KES на ВМ1
# Ставим KES на ВМ2
Готовим новый образ Message Safety
MESSAGE_SAFETY_IMAGE=cr.selcloud.ru/han-images/han-message-safety@sha256:225808bd111be03112ab6693b5a10e3d97ae8cdbe5370a5d0aa5a8e2278a3044
Копируем проект
```powershell
$Release = "1.0.1"
tar --exclude=services/.env `
--exclude='services/**/__pycache__' `
--exclude='services/**/.pytest_cache' `
--exclude='services/**/.ruff_cache' `
-czf "vm2-services-$Release.tar.gz" -C C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM2_services\codebase services
Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256
scp "vm2-services-$Release.tar.gz" prodVM2Deploy:/var/lib/han-deploy/incoming/
scp "vm2-services-$Release.tar.gz" devVM2Deploy:/var/lib/han-deploy/incoming/
```
Проверить размер диска
RELEASE='1.0.1'
EXPECTED_SHA256='1030DE6A50BA945FAEC5A45E1D42C47A01808E737AA1955871F3D2ED089F8879'
ARCHIVE="/var/lib/han-deploy/incoming/vm2-services-${RELEASE}.tar.gz"
printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check -
tar -tvzf "$ARCHIVE"
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then
echo 'ОШИБКА: архив содержит небезопасный путь или .env' >&2
exit 1
fi
if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then
echo 'ОШИБКА: архив содержит файлы вне каталога services' >&2
exit 1
fi
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ { found=1 } END { exit !found }'; then
echo 'ОШИБКА: архив содержит symlink или hardlink' >&2
exit 1
fi
install -d -m 0755 -o root -g root /opt/han-chat/services
STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)"
tar --extract --gzip --file "$ARCHIVE" \
--directory "$STAGING" --no-same-owner --no-same-permissions
test -f "$STAGING/services/docker-compose.yml"
rsync -a --delete --exclude=.env \
--chown=root:root --chmod=D755,F644 \
"$STAGING/services/" /opt/han-chat/services/
rm -rf -- "$STAGING"
chmod 0755 /opt/han-chat/services/deployment/preflight.sh
cd /opt/han-chat/services/
sudo apt update
sudo apt install -y dos2unix
find . -type f \( \
-name '*.sh' -o -name '*.py' -o -name '*.service' -o -name '*.sudoers' -o -name 'han-compose' -o -name 'han-secrets' -o -name 'han-message-safety-mode' -o -name '*.yml' -o -name '*.conf*' -o -name 'preflight.sh' -o -name '*.yaml' -o -name '*.md' -o -name '*.toml' -o -name 'Dockerfile' -o -name '*.acl*' \
\) -exec dos2unix {} +
find -type f -exec file {} \; | grep -i 'CRLF'
sudo apt remove -y dos2unix
sudo apt purge -y dos2unix
Копируем образ на ВМ:
scp "C:\Users\MI\Documents\job\HAN_new_life\HANapp\docs\kes 12.4\049-16-d-01.iso" "C:\Users\MI\Documents\job\HAN_new_life\HANapp\docs\kes 12.4\049-16-d-01.sig" prodVM2Deploy:/var/lib/han-deploy/incoming/
Проверка файла:
apt install -y rhash
ISO='/var/lib/han-deploy/incoming/049-16-d-01.iso'
command -v rhash
rhash --gost "$ISO"
Должно быть 6a94b16afad211e8b9be5ec86f5379184f2b3a9e5869843fe763e2796e5ac1d3
sudo apt remove -y rhash
sudo apt purge -y rhash
Если совпадёт, извлеките DEB:
```sh
install -d -m 0700 -o root -g root \
/root/kesl-install /mnt/kesl-iso
mount -o ro,loop "$ISO" /mnt/kesl-iso
find /mnt/kesl-iso -type f \
\( -iname 'kesl_*_amd64.deb' -o -iname '*.deb' \) |
sort
```
Затем:
```sh
DEB='/mnt/kesl-iso/kesl/kesl_12.4.0-1225_amd64.deb'
dpkg-deb -f "$DEB" Package Version Architecture
sha256sum "$DEB"
install -m 0600 -o root -g root \
"$DEB" /root/kesl-install/kesl.deb
umount /mnt/kesl-iso
rmdir /mnt/kesl-iso
```
Ожидаемые свойства: kesl, версия 12.4.x, архитектура amd64.
После этого можно выполнять:
apt-get install /root/kesl-install/kesl.deb
Далее
```sh
install -m 0600 -o root -g root /dev/null /root/kesl-install/autoinstall.ini
cat > /root/kesl-install/autoinstall.ini <<'EOF'
KSVLA_MODE=No
ENDPOINT_AGENT_MODE=No
EULA_AGREED=Yes
PRIVACY_POLICY_AGREED=Yes
USE_KSN=No
GROUP_CLEAN=Yes
LOCALE=ru_RU.UTF-8
INSTALL_LICENSE=None
UPDATER_SOURCE=KLServers
UPDATE_EXECUTE=No
KERNEL_SRCS_INSTALL=No
USE_GUI=No
CONFIGURE_SELINUX=No
DISABLE_PROTECTION=Yes
INTERCEPTOR_MODE=UseFanotify
ENABLE_TRACES_ON_FIRST_STARTUP=No
EOF
chmod 0600 /root/kesl-install/autoinstall.ini
stat -c '%U:%G:%a %n' /root/kesl-install/autoinstall.ini
/opt/kaspersky/kesl/bin/kesl-setup.pl \
--autoinstall=/root/kesl-install/autoinstall.ini
echo "kesl-setup exit=$?"
systemctl --no-pager status kesl
kesl-control --app-info --json
kesl-control --get-task-list
```
```sh
set +o history
unset HISTFILE
read -rsp 'KESL activation code: ' KESL_CODE; echo
kesl-control --add-active-key "$KESL_CODE"
unset KESL_CODE
set -o history
kesl-control -L --query
```
Не стартуйте Update (task 6) и File Threat Protection (task 1), пока не срежете дефолты памяти. На 8 ГБ `ScanMemoryLimit=8192` и `MaxMemory=auto` (до 50% RAM, минимум 2 ГБ) слишком много, тем более пока жив ClamAV.
Ключ из `-L --query` в evidence/репозиторий не копируйте — достаточно статуса `Active subscription`.
Дальше — секция 3 runbook. Кандидат для ВМ2: `ScanMemoryLimit=1024`, `MaxMemory=2048MB`. CPU: `OnDemandCPULimit=15`, как на пилоте ВМ1, пока ClamAV не снят.
**1. Снимок текущих настроек**
```sh
kesl-control --get-app-settings \
--file /root/kesl-install/app-settings.before.ini
install -m 0600 -o root -g root \
/var/opt/kaspersky/kesl/common/kesl.ini \
/root/kesl-install/kesl.ini.before
awk '/^\[General\]/,/^\[/' /var/opt/kaspersky/kesl/common/kesl.ini | head -n 40
```
**2. Лимит CPU (KESL ещё работает)**
```sh
kesl-control --set-app-settings \
UseOnDemandCPULimit=Yes OnDemandCPULimit=15
```
**3. Память — только при остановленном KESL, файл целиком не подменять**
Сначала посмотрите `[General]` из шага 1. Если там уже есть `ScanMemoryLimit` и `MaxMemory`:
```sh
systemctl stop kesl
systemctl is-active kesl
grep -nE '^(ScanMemoryLimit|MaxMemory)=' /var/opt/kaspersky/kesl/common/kesl.ini
sed -i \
-e 's/^ScanMemoryLimit=8192$/ScanMemoryLimit=1024/' \
-e 's/^MaxMemory=auto$/MaxMemory=2048MB/' \
/var/opt/kaspersky/kesl/common/kesl.ini
grep -nE '^(ScanMemoryLimit|MaxMemory)=' /var/opt/kaspersky/kesl/common/kesl.ini
systemctl start kesl
systemctl is-active kesl
kesl-control --get-app-settings | grep -iE 'UseOnDemandCPULimit|OnDemandCPULimit'
```
**Ожидаемо после рестарта**
- `kesl.service` = `active`
- в settings: `ScanMemoryLimit=1024`, `MaxMemory=2048MB`, `UseOnDemandCPULimit=Yes`, `OnDemandCPULimit=15`
- task 1 и 6 по-прежнему `Stopped`
- базы всё ещё не загружены
После этого — загрузка баз:
`kesl-control --start-task 6 -W`
```sh
kesl-control --get-task-state 6
kesl-control --app-info --json
START="$(LC_ALL=C date +'%Y/%b/%d %H:%M:%S;1')"
kesl-control --set-schedule 6 RuleType=Hourly \
"StartTime=${START}" RunMissedStartRules=No RandomInterval=0
kesl-control --get-schedule 6
```
Обязательный следующий шаг — рестарт KESL. В --app-info есть:
Post-update actions: The application module has been updated. Restart the application.
Task 1 до рестарта не запускайте.
```sh
systemctl restart kesl
systemctl is-active kesl
kesl-control --app-info --json
grep -nE '^(ScanMemoryLimit|MaxMemory)=' /var/opt/kaspersky/kesl/common/kesl.ini
kesl-control --get-app-settings | grep -iE 'UseOnDemandCPULimit|OnDemandCPULimit'
kesl-control --get-schedule 6
kesl-control --get-task-state 1
kesl-control --get-task-state 6
```
```sh
kesl-control --get-settings 1 \
--file /root/kesl-install/file-threat.before.ini
kesl-control --set-settings 1 \
--add-exclusion /var/lib/docker/volumes/han-processing_redis-safety-data/_data
kesl-control --set-settings 1 \
--add-exclusion /var/lib/docker/volumes/han-processing_otel-queue/_data
kesl-control --start-task 1
kesl-control --set-settings 1 \
ActionOnThreat=DisinfectDeleteIfNotPossible ScanArchived=No
kesl-control --get-task-state 1
kesl-control --get-settings 1
```
Сразу снимите coexistence, пока OAS прогревается:
```sh
date -Is
free -h
swapon --show
vmstat 1 5
systemctl is-active kesl docker han-processing.service
kesl-control --app-info --json | grep -E 'File Threat|databases loaded|license'
/usr/local/sbin/han-vm2-compose ps
docker stats --no-stream
```
Установка брокера
```sh
set -Eeuo pipefail
BASE=/opt/han-chat/services/deployment/kesl
for file in \
kesl_scan_broker.py \
han-kesl-scan-broker.service \
han-kesl-scan-broker.socket \
han-kesl-scan-broker.tmpfiles.conf
do
test -f "$BASE/$file"
test ! -L "$BASE/$file"
done
if getent group han-message-safety >/dev/null; then
test "$(getent group han-message-safety | cut -d: -f3)" = 10001
else
! getent group 10001 >/dev/null
groupadd --system --gid 10001 han-message-safety
fi
install -d -m 0755 -o root -g root /usr/local/libexec
install -m 0750 -o root -g root \
"$BASE/kesl_scan_broker.py" \
/usr/local/libexec/han-kesl-scan-broker
install -m 0644 -o root -g root \
"$BASE/han-kesl-scan-broker.service" \
/etc/systemd/system/han-kesl-scan-broker.service
install -m 0644 -o root -g root \
"$BASE/han-kesl-scan-broker.socket" \
/etc/systemd/system/han-kesl-scan-broker.socket
install -m 0644 -o root -g root \
"$BASE/han-kesl-scan-broker.tmpfiles.conf" \
/etc/tmpfiles.d/han-kesl-scan-broker.conf
systemd-tmpfiles --create \
/etc/tmpfiles.d/han-kesl-scan-broker.conf
systemctl daemon-reload
systemd-analyze verify \
/etc/systemd/system/han-kesl-scan-broker.service \
/etc/systemd/system/han-kesl-scan-broker.socket
```
Если systemd-analyze verify завершился без ошибок, запуск:
```sh
systemctl enable --now han-kesl-scan-broker.socket
systemctl is-active kesl han-kesl-scan-broker.socket
stat -c '%U:%G:%a %n' /run/han-kesl /run/han-kesl/scan.sock
/usr/local/libexec/han-kesl-scan-broker --probe
```
Ожидаемые права:
/run/han-kesl root:han-message-safety:750
/run/han-kesl/scan.sock root:han-message-safety:660
Создаём папку:
```sh
install -d -m 0700 -o root -g han-message-safety \
/var/lib/han-kesl-scan \
/var/lib/han-kesl-scan/staging
```
Проверка и запуск socket
```
systemd-analyze verify \
/etc/systemd/system/han-kesl-scan-broker.service \
/etc/systemd/system/han-kesl-scan-broker.socket
systemctl enable --now han-kesl-scan-broker.socket
systemctl is-active kesl han-kesl-scan-broker.socket
stat -c '%U:%G:%a %n' /run/han-kesl /run/han-kesl/scan.sock
/usr/local/libexec/han-kesl-scan-broker --probe
journalctl --since '-10 min' --no-pager -u han-kesl-scan-broker.service
```
Обновить .env
editor /opt/han-chat/services/.env
MESSAGE_SAFETY_IMAGE=cr.selcloud.ru/han-images/han-message-safety@sha256:225808bd111be03112ab6693b5a10e3d97ae8cdbe5370a5d0aa5a8e2278a3044
Удалить
CLAMAV_IMAGE
MESSAGE_SAFETY_CLAMAV_HOST
MESSAGE_SAFETY_CLAMAV_PORT
Добавить
MESSAGE_SAFETY_ANTIVIRUS_SOCKET=/run/han-kesl/scan.sock
Активировать config schema 2
NEXT_VERSION='2'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
validate /app/app/artifacts/seed-config.yaml
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml \
--version "$NEXT_VERSION" --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version "$NEXT_VERSION" --approved-by '<APPROVER>'
Вернуть затертые IP
editor /opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf
allow 195.208.184.200;
allow 89.208.230.2;
allow 195.208.184.34;
allow 89.208.228.219;
deny all;
editor /opt/han-chat/services/nginx/allowlists/private-caller-allowlist.conf
allow 10.0.0.11;
deny all;
Переключить только Message Safety
cd /opt/han-chat/services/
deployment/preflight.sh
/usr/local/sbin/han-vm2-compose config --quiet
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose ps
/usr/local/sbin/han-vm2-compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}"
Тестирование:
Секция 7 — не один скрипт, а **пять независимых gates**. EICAR только после письменного Security; образец не класть в git, chat и evidence — брать с [eicar.org](https://www.eicar.org). Пункты 45 и cutover **нельзя** закрыть текущим образом Message Safety (он ещё на ClamAV): сначала 1–3 на хосте.
Все команды на `devhanservice` — под **root**. `han-processing.service` не restart/stop.
---
### 0. Перед тестами
```sh
kesl-control --get-task-state 1
kesl-control --app-info --json | grep -E 'File Threat|databases loaded|license'
systemctl is-active kesl han-kesl-scan-broker.socket
test -S /run/han-kesl/scan.sock
stat -c '%U:%G:%a %n' /run/han-kesl/scan.sock
/usr/local/libexec/han-kesl-scan-broker --probe
```
Нужно: task 1 `Started`, базы `Yes`, socket `root:han-message-safety:660`, probe `"status":"ready"`. Если sock нет — `systemctl restart han-kesl-scan-broker.socket`, не `.service`.
---
### 1. Host OAS (АВЗ.1)
Каталог **не** в docker volume, `/tmp`, release, staging:
```sh
install -d -m 0700 -o root -g root /root/kesl-eicar-test
date -Is
kesl-control --get-task-state 1
```
Положите официальный `eicar.com` в `/root/kesl-eicar-test/eicar.com`:
curl -fsSL -o /root/kesl-eicar-test/eicar.com \
'https://secure.eicar.org/eicar.com'
chmod 0600 /root/kesl-eicar-test/eicar.com
```sh
cat /root/kesl-eicar-test/eicar.com >/dev/null
ls -l /root/kesl-eicar-test
kesl-control -E --query -n 20 --reverse
```
Ожидаемо: объект удалён/в Backup, в журнале `EICAR-Test-File` / `ThreatDetected`. Если OAS на запись молчит (как на пилоте ВМ1) — явный ODS:
```sh
kesl-control --scan-file /root/kesl-eicar-test/eicar.com
kesl-control -E --query -n 20 --reverse
```
Уборка (не весь Backup):
```sh
kesl-control -B --query --reverse -n 20
kesl-control -B --mass-remove --query "DetectName == 'EICAR-Test-File'"
rm -rf /root/kesl-eicar-test
```
---
### 2. Broker: clean / EICAR / KESL down
install -d -m 0700 -o root -g root /root/kesl-install
cat > /root/kesl-install/broker-client.py <<'PY'
#!/usr/bin/env python3
import asyncio, json, struct, sys
SOCK = "/run/han-kesl/scan.sock"
async def call(op: str, body: bytes = b"") -> dict:
r, w = await asyncio.open_unix_connection(SOCK)
header = {"op": op}
if op == "scan":
header["size"] = len(body)
raw = json.dumps(header, separators=(",", ":")).encode()
w.write(struct.pack(">I", len(raw)) + raw + body)
await w.drain()
n = struct.unpack(">I", await r.readexactly(4))[0]
resp = json.loads(await r.readexactly(n))
w.close()
await w.wait_closed()
return resp
async def main() -> None:
op = sys.argv[1]
body = sys.stdin.buffer.read() if op == "scan" else b""
print(json.dumps(await call(op, body), ensure_ascii=True, sort_keys=True))
if __name__ == "__main__":
if sys.argv[1:] not in (["status"], ["scan"]):
raise SystemExit("usage: broker-client.py status|scan")
asyncio.run(main())
PY
chmod 0700 /root/kesl-install/broker-client.py
Проверка позитивного решения
python3 /root/kesl-install/broker-client.py status
printf 'HAN kesl broker clean canary\n' | python3 /root/kesl-install/broker-client.py scan
Проверка негативного решения
curl -fsSL 'https://secure.eicar.org/eicar.com' |
python3 /root/kesl-install/broker-client.py scan
Проверка недоступного сканнера
systemctl stop kesl
systemctl is-active kesl || true
python3 /root/kesl-install/broker-client.py status
printf 'error canary\n' |
python3 /root/kesl-install/broker-client.py scan
Оба запроса должны вернуть scanner_unavailable.
Затем восстановить:
systemctl start kesl
systemctl is-active kesl
kesl-control --start-task 1
kesl-control --get-task-state 1
systemctl restart han-kesl-scan-broker.socket
/usr/local/libexec/han-kesl-scan-broker --probe
---
### 3. 5 слотов и ~2 файла/с
На 4 ГБ DEV это нагрузка: смотрите `free -h` / `vmstat`. Откат OAS: `kesl-control --stop-task 1`.
```sh
python3 - <<'PY'
import asyncio, json, struct, time, statistics
SOCK = "/run/han-kesl/scan.sock"
BODY = b"HAN kesl throughput canary\n"
async def scan():
r, w = await asyncio.open_unix_connection(SOCK)
h = json.dumps({"op": "scan", "size": len(BODY)}, separators=(",", ":")).encode()
t0 = time.perf_counter()
w.write(struct.pack(">I", len(h)) + h + BODY)
await w.drain()
n = struct.unpack(">I", await r.readexactly(4))[0]
resp = json.loads(await r.readexactly(n))
dt = time.perf_counter() - t0
w.close(); await w.wait_closed()
return dt, resp
async def main():
burst = await asyncio.gather(*[scan() for _ in range(5)])
print("burst5", [(round(dt, 3), r.get("verdict") or r.get("error")) for dt, r in burst])
times = []
for _ in range(20):
dt, r = await scan()
assert r.get("verdict") == "clean", r
times.append(dt)
await asyncio.sleep(0.5) # ~2 file/s
times.sort()
p95 = times[int(0.95 * (len(times) - 1))]
print(f"n={len(times)} p50={times[len(times)//2]:.3f}s p95={p95:.3f}s max={times[-1]:.3f}s")
asyncio.run(main())
PY
```
Gate: все `clean`, p95 ≤ 60 с. Queue age p95 ≤ 5 с — из метрик Safety (после пункта 4), не из этого скрипта.
---
### 4. Message Safety 200 / 403 / 503
Сейчас контейнеры ещё на ClamAV. Пункт 4 — **после** recreate `message-safety-api`/`worker` на образ с bind `/run/han-kesl` (digest из окна 1.0.1). Clamd пока можно не снимать.
Проверка, что API видит KESL:
```sh
/usr/local/sbin/han-vm2-compose exec -T message-safety-api \
python -c 'import json,urllib.request; print(json.dumps(json.load(urllib.request.urlopen("http://127.0.0.1:8080/health/ready")), indent=2))'
```
`antivirus=ok`, `files=ready` (не mock).
- **clean `200`** — text canary с ВМ1, как в `RUNBOOK.ru.md` (`/internal/safety/v2/messages/check`, `content_kind=text`).
- **malware `403` + sticky** — file через **реальный** versioned object в quarantine S3 (выдуманный key нельзя; EICAR в bucket только если Security разрешил). Повтор того же `message_id`+body — тот же `403`.
- **scanner error `503`**`systemctl stop kesl`, file-check, ожидание `503` / files unavailable; затем start kesl + restart **socket** + `--start-task 1`.
Пока worker ходит в clamd, это **не** gate KESL.
---
### 5. Smoke, firewall, 24 ч
```sh
iptables -S HAN-CHAT-VM2
ufw status verbose
/usr/local/sbin/han-vm2-compose ps
free -h
swapon --show
kesl-control --get-task-state 6
kesl-control --get-schedule 6
```
С ВМ1: `openssl s_client` + `curl` `/internal/safety/status` из `RUNBOOK.ru.md`. Сравните `HAN-CHAT-VM2` с baseline. 24 ч: без OOM/restart/5xx, hourly Update сам зелёный.
curl --fail --silent --show-error \
--cacert /etc/han/ca/vm2-internal-ca.crt \
https://processing.internal:8443/internal/safety/status
https://prodhanservice.ihan.ru:8443/internal/safety/status
---
### Cutover (только после 1–5)
Новая **монотонная** config version (не реактивировать старую):
```sh
cd /opt/han-chat/services/
NEXT_VERSION='3'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml \
--version "$NEXT_VERSION" --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version "$NEXT_VERSION" --approved-by '<APPROVER>'
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
message-safety-api message-safety-worker
```
В `.env` / compose не должно остаться `clamd`/`freshclam`. Затем:
```sh
/usr/local/sbin/han-vm2-compose up -d --remove-orphans
/usr/local/sbin/han-vm2-compose ps
docker ps -a --format '{{.Names}}' | grep -E 'clamd|freshclam' || echo 'no clamd/freshclam'
docker volume ls | grep -i clam || true
```
/usr/local/sbin/han-vm2-compose exec -T nginx nginx -t -c /etc/nginx/nginx.conf
NGINX_ID=$(/usr/local/sbin/han-vm2-compose ps --status running --quiet nginx)
docker kill --signal HUP "$NGINX_ID"
/usr/local/sbin/han-vm2-compose exec -T nginx getent hosts message-safety-api
/usr/local/sbin/han-vm2-compose exec -T message-safety-api \
python -c 'import json,urllib.request; print(json.dumps(json.load(urllib.request.urlopen("http://127.0.0.1:8080/health/ready")), indent=2))'
Старый signature-CDN egress закрывается отдельно в firewall/SG — не через `compose down -v`.
На этом DEV-хосте (3.8 ГиБ, swap 391 МиБ) пункт 3 и file-EICAR через Safety лучше не совмещать с чем-то ещё. Если нужно, могу выписать только gate 1+2 компактным блоком под копирование в `260907_1.md`.
@@ -5,4 +5,9 @@
| grep -B10 -A40 'bfffb1c2-b938-4443-9c42-5a9b87fcf6b3'
/usr/local/sbin/han-vm1-compose logs --since=30m api-backend \
| grep -B5 -A20 'request.failed'
| grep -B5 -A20 'request.failed'
--по ошибке отправки файла на ВМ1:
RID=0c44e81c-f2e1-4018-9288-92a97a6d0443
/usr/local/sbin/han-vm1-compose logs --no-color --since 30m nginx api-backend 2>&1 \
| grep -F "$RID"
@@ -1,3 +1,7 @@
> Архивная инструкция. После перехода ВМ2 на host KESL 12.4 и локальный
> scan-broker ClamAV удалён из целевого Compose. Для эксплуатации использовать
> `VM2_services/codebase/services/deployment/kesl/RUNBOOK.KESL.ru.md`.
### 1. Получите digest локально
В WSL: