From bb5dd6538e00c12e4df19ada80162e07b618e67f Mon Sep 17 00:00:00 2001 From: Repinoid Date: Fri, 30 Oct 2026 12:08:55 +0300 Subject: [PATCH] Fix Contracts upload buffer endpoint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- deploy/contracts-upload-cleanup.cron | 2 + deploy/nginx-contracts-upload-acme.conf | 16 +++ deploy/nginx-contracts-upload.conf | 50 +++++++ deploy/nginx-contracts.conf | 16 --- docs/upload-buffer-incident-2026-10-30.md | 157 ++++++++++++++++++++++ site/config.py | 6 +- site/static/app.js | 2 +- tests/load/stress_http.py | 2 +- upload/README.md | 17 +++ upload/backend/upload_refs/config.py | 2 +- upload/config.example.json | 4 +- 11 files changed, 250 insertions(+), 24 deletions(-) create mode 100644 deploy/contracts-upload-cleanup.cron create mode 100644 deploy/nginx-contracts-upload-acme.conf create mode 100644 deploy/nginx-contracts-upload.conf create mode 100644 docs/upload-buffer-incident-2026-10-30.md diff --git a/deploy/contracts-upload-cleanup.cron b/deploy/contracts-upload-cleanup.cron new file mode 100644 index 0000000..17f6b6e --- /dev/null +++ b/deploy/contracts-upload-cleanup.cron @@ -0,0 +1,2 @@ +# Remove abandoned temporary upload objects after 30 minutes. +*/5 * * * * root find /var/www/contracts-upload/ -type f -mmin +30 -delete diff --git a/deploy/nginx-contracts-upload-acme.conf b/deploy/nginx-contracts-upload-acme.conf new file mode 100644 index 0000000..dab94b5 --- /dev/null +++ b/deploy/nginx-contracts-upload-acme.conf @@ -0,0 +1,16 @@ +# Temporary HTTP-only vhost for the initial Let's Encrypt HTTP-01 challenge. +# Install as /etc/nginx/sites-enabled/contracts-upload.conf, issue the +# certificate, then replace it with nginx-contracts-upload.conf. + +server { + listen 80; + server_name contracts-upload.sinefo.ru; + + location /.well-known/acme-challenge/ { + root /var/www/acme; + } + + location / { + return 404; + } +} diff --git a/deploy/nginx-contracts-upload.conf b/deploy/nginx-contracts-upload.conf new file mode 100644 index 0000000..df0aa6a --- /dev/null +++ b/deploy/nginx-contracts-upload.conf @@ -0,0 +1,50 @@ +# Dedicated WebDAV upload buffer for Contracts on VM213. +# Install as /etc/nginx/sites-enabled/contracts-upload.conf. + +# sites-enabled is included from Nginx's http context, where map is valid. +map $http_origin $contracts_upload_cors_origin { + default ""; + ~^https://contractor\.pythonk8s\.dev\.nubes\.ru$ $http_origin; +} + +server { + listen 80; + server_name contracts-upload.sinefo.ru; + + location /.well-known/acme-challenge/ { + root /var/www/acme; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name contracts-upload.sinefo.ru; + + ssl_certificate /etc/letsencrypt/live/contracts-upload.sinefo.ru/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/contracts-upload.sinefo.ru/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Match the app's 50 MiB per-file limit; uploads are raw PUT bodies. + client_max_body_size 50m; + + location /contracts-upload/ { + alias /var/www/contracts-upload/; + dav_methods PUT DELETE; + create_full_put_path on; + client_max_body_size 50m; + add_header Access-Control-Allow-Origin $contracts_upload_cors_origin always; + add_header Access-Control-Allow-Methods "PUT, GET, OPTIONS, DELETE" always; + add_header Access-Control-Allow-Headers "Content-Type" always; + add_header Access-Control-Max-Age 86400 always; + if ($request_method = OPTIONS) { return 204; } + } + + location / { + return 404; + } +} diff --git a/deploy/nginx-contracts.conf b/deploy/nginx-contracts.conf index a0e49c1..ec898be 100644 --- a/deploy/nginx-contracts.conf +++ b/deploy/nginx-contracts.conf @@ -92,22 +92,6 @@ server { client_max_body_size 100m; } - # ── VM-буфер загрузки contracts (паттерн drhider) ────────────── - # Браузер кладёт файл сюда PUT (мимо шлюза кластера ~64КБ), бэк тянет сам. - # Файлы: /var/www/contracts-upload/ (нужно создать, chown www-data). - # CORS: origin фронтенда = contractor.pythonk8s.dev.nubes.ru (ingress, подтверждено). - location /contracts-upload/ { - alias /var/www/contracts-upload/; - dav_methods PUT DELETE; - create_full_put_path on; - client_max_body_size 1024m; - add_header Access-Control-Allow-Origin https://contractor.pythonk8s.dev.nubes.ru always; - add_header Access-Control-Allow-Methods 'PUT, GET, OPTIONS, DELETE' always; - add_header Access-Control-Allow-Headers 'Content-Type' always; - add_header Access-Control-Max-Age 86400 always; - if ($request_method = OPTIONS) { return 204; } - } - listen 443 ssl; # managed by Certbot ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot diff --git a/docs/upload-buffer-incident-2026-10-30.md b/docs/upload-buffer-incident-2026-10-30.md new file mode 100644 index 0000000..5be69dd --- /dev/null +++ b/docs/upload-buffer-incident-2026-10-30.md @@ -0,0 +1,157 @@ +# Инцидент: ошибки загрузки файлов через VM-буфер — 2026-10-30 + +## Краткий вывод + +Контрактор отправлял выбранные файлы на `https://contracts.kube5s.ru/contracts-upload/`. +На момент проверки 2026-10-30 имя `contracts.kube5s.ru` возвращало `NXDOMAIN` у +публичных DNS-резолверов `1.1.1.1` и `8.8.8.8`. Поэтому браузер не мог разрешить +адрес назначения и показывал общую ошибку «Сетевая ошибка при отправке файла в +буфер». Запрос не доходил до Nginx и WebDAV. + +Форматы и содержимое выбранных документов не были причиной этого сбоя. Реальные +файлы из пользовательской папки не отправлялись; проверки upload-инфраструктуры +ограничились DNS, чтением конфигураций и безопасными HTTP `OPTIONS`-запросами. + +## Подтверждённые факты + +### Настройка Контрактора + +- Развёрнутая страница Контрактора показывала версию `2.0.23`. +- Загруженный браузером [`site/static/app.js`](../site/static/app.js) содержит + `VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/'`. +- [`site/config.py`](../site/config.py) задаёт тот же домен для `VM_UPLOAD_URL` + и `VM_UPLOAD_PREFIX`. Backend регистрирует `upload_refs` с этим + `VM_UPLOAD_PREFIX` через [`site/routes/__init__.py`](../site/routes/__init__.py). +- [`deploy/nginx-contracts.conf`](../deploy/nginx-contracts.conf) содержит + WebDAV location `/contracts-upload/` с каталогом + `/var/www/contracts-upload/` и CORS origin + `https://contractor.pythonk8s.dev.nubes.ru`. Этот location находится в + virtual host `contracts.kube5s.ru`. + +### DNS и доступные буферы + +- Запросы к `1.1.1.1` и `8.8.8.8` для `contracts.kube5s.ru A` вернули + `NXDOMAIN`. +- После первоначальной диагностики владелец DNS добавил + `contracts-upload.sinefo.ru A -> 5.172.178.213`. Повторная проверка обоих + публичных резолверов подтвердила эту запись. +- При первой проверке до настройки Nginx новый hostname отдавал чужой TLS- + сертификат. После установки HTTP challenge vhost сертификат Let's Encrypt + выпущен для `contracts-upload.sinefo.ru`; срок действия до 2027-01-28. +- Nginx virtual host для нового hostname теперь установлен на VM213, а + `nginx -t` и reload прошли успешно. +- `upload.sinefo.ru` разрешался в `5.172.178.213`, но + `https://upload.sinefo.ru/contracts-upload/...` отвечал `404`. +- На `upload.sinefo.ru/drhider-upload/` WebDAV-маршрут существует, но + `Access-Control-Allow-Origin` возвращается только для DrHider origin + `https://drhider.pythonk8s.dev.nubes.ru`, не для Контрактора. +- На `obfuscator-upload.sinefo.ru/obfuscator-upload/` маршрут существует, но + `Access-Control-Allow-Origin` возвращается только для Obfuscator origin + `https://obfuscator.pythonk8s.services.ngcloud.ru`, не для Контрактора. +- Запросы `OPTIONS` для origin Контрактора к обоим существующим маршрутам + возвращали `204`, но без `Access-Control-Allow-Origin`. Такой ответ не + разрешает браузерный запрос; кроме того, путь `/contracts-upload/` на этих + хостах не настроен. +- На VM213 (`5.172.178.213`) при проверке существовали каталог + `/var/www/contracts-upload/` и старый Nginx location `/contracts-upload/` + внутри virtual host `contracts.kube5s.ru`. Наличие каталога само по себе не + делает его доступным по другому hostname. + +## Почему прямое исправление URL недостаточно + +Замена только домена в Контракторе не решит проблему: + +1. `/contracts-upload/` на `upload.sinefo.ru` сейчас не настроен и отвечает + `404`. +2. Существующий `/drhider-upload/` предназначен для DrHider и не разрешает + origin Контрактора в CORS. +3. Буфер Obfuscator имеет отдельные hostname и путь, отдельную конфигурацию и + allowlist origin. Он не является готовым Contracts endpoint. +4. URL на фронтенде и доверенный префикс backend должны совпадать. Иначе браузер + может загрузить объект, но `upload_refs` отклонит URL при SSRF-проверке либо + backend будет пытаться забрать объект с другого адреса. +5. Backend `VM_UPLOAD_PREFIX` может быть переопределён переменной окружения и + должен совпадать с URL буфера. URL браузерной загрузки задаётся константой + `VM_UPLOAD_URL` в `site/static/app.js`; переменная окружения с таким именем + сама по себе статический JavaScript не меняет. + +## Отличие от Obfuscator + +Актуальный Obfuscator использует согласованную отдельную конфигурацию: + +- hostname `obfuscator-upload.sinefo.ru`; +- путь `/obfuscator-upload/`; +- отдельный каталог `/var/www/obfuscator-upload/`; +- CORS allowlist для `https://obfuscator.pythonk8s.services.ngcloud.ru`; +- один и тот же URL-префикс на фронтенде и backend. + +Это описано в [`OBFUSCATOR/README.md`](../../../drhider/OBFUSCATOR/README.md) и +настроено в +[`obfuscator-upload.conf`](../../../drhider/OBFUSCATOR/deploy/vm213/nginx/obfuscator-upload.conf). +Контрактор должен получить аналогично согласованный endpoint, а не использовать +Obfuscator/DrHider путь без изменения и проверки его конфигурации. + +## План исправления + +Первоначальный план обновлён после предоставления DNS-записи +`contracts-upload.sinefo.ru`. Код и source Nginx изменены локально; VM213 и +развёрнутое приложение ещё не обновлялись. + +1. **Hostname и DNS — выполнено.** Выбран `contracts-upload.sinefo.ru`; + публичные DNS `1.1.1.1` и `8.8.8.8` возвращают `5.172.178.213`. +2. **Настроить VM213 — выполнено.** Проверен отдельный каталог + `/var/www/contracts-upload/` и права пользователя Nginx. Для первоначального + выпуска сертификата установить + [`deploy/nginx-contracts-upload-acme.conf`](../deploy/nginx-contracts-upload-acme.conf) + как `/etc/nginx/sites-enabled/contracts-upload.conf`, выполнить `nginx -t` + и только при успехе reload. Первая попытка Certbot завершилась временным + CAA DNS timeout; повторная попытка выпустила сертификат. После этого + временный конфиг заменён полным + [`deploy/nginx-contracts-upload.conf`](../deploy/nginx-contracts-upload.conf), + снова выполнить `nginx -t` и reload. Полный vhost задаёт отдельный WebDAV + путь, `PUT`/`GET`/`DELETE`, `OPTIONS`, ограничение тела 50 MiB и CORS allowlist + через `map` для точного origin `https://contractor.pythonk8s.dev.nubes.ru`. + Предел `50m` соответствует backend-лимиту 50 MiB; не копировать 1024 MiB из + Obfuscator, поскольку Contracts не принимает файлы такого размера. Установить + [`deploy/contracts-upload-cleanup.cron`](../deploy/contracts-upload-cleanup.cron) + как `/etc/cron.d/contracts-upload-cleanup`, чтобы удалять оставшиеся объекты + старше 30 минут. Cron установлен в `/etc/cron.d/contracts-upload-cleanup`. + Не менять маршруты DrHider или Obfuscator и не объединять их каталоги с Contracts. +3. **Код приложения — выполнено локально, deploy ожидает выполнения.** + Frontend `VM_UPLOAD_URL` и backend `VM_UPLOAD_PREFIX` default приведены к + `https://contracts-upload.sinefo.ru/contracts-upload/`; обновлены + frontend/backend defaults, пример конфига и default URL нагрузочного теста. + Версия приложения поднята до `2.0.24`. Проверить переменные окружения + развёртывания: `VM_UPLOAD_PREFIX` не должен переопределять новый endpoint + старым значением. +4. **Синхронизировать конфигурацию VM — выполнено.** Источник Nginx вынесен в + отдельные bootstrap- и TLS-конфиги; старый upload location удалён из + [`deploy/nginx-contracts.conf`](../deploy/nginx-contracts.conf). Конфигурация + TLS-vhost и cron установлена на VM213; Nginx config test/reload успешны. + Деплой приложения Контрактора — отдельная операция, ещё не выполнена. +5. **Проверить endpoint до обработки документов — выполнено.** + - DNS разрешается в `5.172.178.213`; TLS сертификат валиден. + - `OPTIONS` с origin Контрактора отвечает `204` и содержит точный + `Access-Control-Allow-Origin`, разрешённые методы и заголовки. С + посторонним origin `Access-Control-Allow-Origin` не отправляется. + - Синтетический объект прошёл `PUT` (`201`) → `GET` (байты совпали) → + `DELETE` (`204`). Объект удалён. +6. **Проверить путь через приложение безопасно.** В тестовой сессии/батче + загрузить синтетический небольшой файл, убедиться в успешном + `POST /api/upload_refs` и очистить созданную тестовую запись/объект. Не + использовать пользовательские документы для проверки, пока синтетический + тест не пройден. +7. **Только после успешной проверки** проверить обычную загрузку и последующие + этапы обработки выбранных документов. + +## Статус + +- Причина исходной сетевой ошибки подтверждена: используемый hostname + `contracts.kube5s.ru` вернул `NXDOMAIN`. +- DNS, TLS, Nginx vhost, CORS и прямой синтетический WebDAV цикл проверены + успешно. +- Причина установлена до этапов HTTP/WebDAV, CORS и обработки документов. +- URL/defaults, версия приложения и source Nginx обновлены локально. +- Конфигурация буфера и cron уже установлены на VM213. +- Изменения приложения ещё не запушены и не redeployed; end-to-end загрузка + через UI Контрактора и обработка документов ещё не проверены. diff --git a/site/config.py b/site/config.py index 0d46bdc..aded065 100644 --- a/site/config.py +++ b/site/config.py @@ -1,7 +1,7 @@ """Конфигурация приложения — все настройки в одном месте.""" import os -VERSION = "2.0.23" +VERSION = "2.0.24" LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions") LLM_KEY = os.getenv("LLM_API_KEY", "") @@ -14,9 +14,9 @@ CONVERT_SERVICE_URL = os.getenv("CONVERT_SERVICE_URL", "http://containerk8s.df36 # ── VM-буфер загрузки (паттерн drhider) ────────────────────────────── # Браузер кладёт файл на ВМ через WebDAV (мимо шлюза кластера ~64КБ), # бэк сам тянет его исходящим GET (egress без лимита). -VM_UPLOAD_URL = os.getenv("VM_UPLOAD_URL", "https://contracts.kube5s.ru/contracts-upload/") +VM_UPLOAD_URL = os.getenv("VM_UPLOAD_URL", "https://contracts-upload.sinefo.ru/contracts-upload/") # SSRF-защита: тянуть можно ТОЛЬКО с этого префикса. -VM_UPLOAD_PREFIX = os.getenv("VM_UPLOAD_PREFIX", "https://contracts.kube5s.ru/contracts-upload/") +VM_UPLOAD_PREFIX = os.getenv("VM_UPLOAD_PREFIX", "https://contracts-upload.sinefo.ru/contracts-upload/") # Лимит на один файл (совпадает с фронтом). VM_UPLOAD_MAX_BYTES = int(os.getenv("VM_UPLOAD_MAX_BYTES", str(50 * 1024 * 1024))) # Ретраи pull с ВМ (разовые DNS/сетевые сбои не роняют загрузку). diff --git a/site/static/app.js b/site/static/app.js index ad5ea38..c2c2c9a 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -3,7 +3,7 @@ var VM_API = ''; // ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза), // бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ. -var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/'; +var VM_UPLOAD_URL = 'https://contracts-upload.sinefo.ru/contracts-upload/'; // SITE_URL удалён (Фаза 4) — не использовался // state.files — теперь в state.js (Фаза 0: state + render) diff --git a/tests/load/stress_http.py b/tests/load/stress_http.py index 55e2796..a5c05dc 100644 --- a/tests/load/stress_http.py +++ b/tests/load/stress_http.py @@ -26,7 +26,7 @@ import httpx def main(): ap = argparse.ArgumentParser(description="HTTP-стресс contracts-flask") ap.add_argument("--url", default="http://127.0.0.1:5000") - ap.add_argument("--vm-url", default="https://contracts.kube5s.ru/contracts-upload/", help="ВМ WebDAV-буфер (PUT файла)") + ap.add_argument("--vm-url", default="https://contracts-upload.sinefo.ru/contracts-upload/", help="ВМ WebDAV-буфер (PUT файла)") ap.add_argument("--n", type=int, default=1000, help="число запросов") ap.add_argument("--threads", type=int, default=32, help="параллельных потоков") ap.add_argument("--size", type=int, default=100000, help="размер файла в байтах (если не задан --file)") diff --git a/upload/README.md b/upload/README.md index f660f86..b08271c 100644 --- a/upload/README.md +++ b/upload/README.md @@ -154,3 +154,20 @@ store_result(res.session, result_zip) # если нужно отдать - Слой 3 — логика приложения (обработка файлов из сессии, SSE-прогресс) у каждого своя. - CORS на ВМ-буфере — если домен приложения другой, правится nginx на ВМ, а не код модуля. + +## Инцидент Contracts VM-буфера (2026-10-30) + +Контрактор использовал `contracts.kube5s.ru/contracts-upload/`, но DNS для +`contracts.kube5s.ru` возвращал `NXDOMAIN`. DNS-запись для отдельного hostname +`contracts-upload.sinefo.ru` теперь добавлена и проверена на публичных +резолверах. Переключение только на существующий +DrHider или Obfuscator endpoint недостаточно: у них другие пути и CORS allowlist, +и тестовый путь `/contracts-upload/` на этих хостах отвечал `404`. Новый +hostname теперь настроен на VM213 с отдельным TLS-сертификатом, Nginx vhost и +cron-очисткой временных файлов. Проверены HTTPS, CORS для Контрактора и прямой +синтетический цикл PUT/GET/DELETE. + +Код приложения и source Nginx настроены локально; Nginx-конфиг буфера уже +установлен на VM213, но само приложение пока не redeployed. Причина, результаты +DNS/CORS/TLS-проверок и дальнейший план описаны в +[отчёте об инциденте](../docs/upload-buffer-incident-2026-10-30.md). diff --git a/upload/backend/upload_refs/config.py b/upload/backend/upload_refs/config.py index 1a6dae4..f06c271 100644 --- a/upload/backend/upload_refs/config.py +++ b/upload/backend/upload_refs/config.py @@ -8,4 +8,4 @@ PULL_RETRIES = 3 PULL_RETRY_DELAY = 2 # секунды между попытками # Доверенный префикс ВМ-буфера — валидация URL при pull (защита от SSRF) -VM_UPLOAD_PREFIX = "https://contracts.kube5s.ru/drhider-upload/" +VM_UPLOAD_PREFIX = "https://contracts-upload.sinefo.ru/contracts-upload/" diff --git a/upload/config.example.json b/upload/config.example.json index 037494d..ee86bf7 100644 --- a/upload/config.example.json +++ b/upload/config.example.json @@ -1,10 +1,10 @@ { - "vmUploadUrl": "https://contracts.kube5s.ru/drhider-upload/", + "vmUploadUrl": "https://contracts-upload.sinefo.ru/contracts-upload/", "allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"], "maxFileBytes": 52428800, "maxSessionBytes": 524288000, "apiPrefix": "/api", - "vmUploadPrefix": "https://contracts.kube5s.ru/drhider-upload/", + "vmUploadPrefix": "https://contracts-upload.sinefo.ru/contracts-upload/", "pullRetries": 3, "pullRetryDelay": 2, "pullTimeout": 120,