diff --git a/History/2026-08-21-pattern-vm-buffer-pull-plan.md b/History/2026-08-21-pattern-vm-buffer-pull-plan.md new file mode 100644 index 0000000..01afe46 --- /dev/null +++ b/History/2026-08-21-pattern-vm-buffer-pull-plan.md @@ -0,0 +1,124 @@ +# ПЛАН: перенос паттерна «ВМ-буфер + pull» в drhider + +_Дата: 2026-08-21. Основа: `contracts/loadtest/History/2026-08-21-pattern-vm-buffer-pull.md`._ +_Статус: план, код НЕ менялся. Писать будет DeepSeek Flash._ + +--- + +## Цель + +drhider (Managed Flask, Штурвал, `drhider.pythonk8s.dev.nubes.ru`) получает файлы через +`POST /api/upload` (multipart). Входной шлюз managed-кластера обрывает тела >~64КБ +(HTTP 000 ~10с). Решение — загружать файл на ВМ напрямую, Flask тянет сам (egress). + +## Директива (обязательно) + +**ВСЕ файловые операции загрузки в Flask (из браузера или вообще снаружи) — ТОЛЬКО через ВМ.** +Никаких прямых `POST /api/upload` с телом файла во Flask. + +## Среда (уточнено 2026-08-21) + +- `iot-naeel` — СОБСТВЕННЫЙ кластер, параметры/аннотации правились через kubectl. Пока + тестируем/смотрим поведение на нём (лимит 64КБ там может не воспроизводиться). +- В дальнейшем деплой — на ОБЫЧНЫЙ managed-кластер (лимит 64КБ будет реальным). +- ВМ — ОДНА (`5.172.178.213`, `contracts.kube5s.ru`), та же, что для loadtest. Location + `/drhider-upload/` добавляется в существующий nginx рядом с `/lt-serve/`. +- Вывод: VM-путь строим СЕЙЧАС, независимо от поведения iot-naeel — прод всё равно на обычном. + +## Текущий поток + +`uploadFiles()` (index.html ~413): по одному файлу в цикле → `FormData` → `POST /api/upload`. +Бэк: `MAX_CONTENT_LENGTH=200МБ`, сессия 500МБ. HTTP-клиент — **httpx** (не requests). + +## Целевой поток + +``` +Браузер ──(PUT, файл целиком)──▶ ВМ nginx (без лимита 64КБ) + │ └─ получил URL + токен + │ + └──▶ Flask POST /api/upload_refs (JSON: [{name,size,url}], <64КБ) + │ + ▼ + Flask egress GET с ВМ ──▶ читает файл ──▶ add_file() ──▶ дальше как сейчас +``` + +## Изменения (4 места) + +### 1. ВМ `5.172.178.213`, `nginx-contracts.conf` — приём загрузки +Новый location (рядом с `/lt-serve/`), приём через PUT + статическая отдача: +```nginx +location /drhider-upload/ { + alias /var/www/drhider-upload/; + dav_methods PUT; + create_full_put_path on; + client_max_body_size 1024m; + # CORS: браузер с drhider.pythonk8s.dev.nubes.ru шлёт PUT (не simple → preflight) + add_header Access-Control-Allow-Origin https://drhider.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; + if ($request_method = OPTIONS) { return 204; } +} +``` +- Каталог `/var/www/drhider-upload/` (владелец `www-data`), НЕ `/home/naeel` (403). +- Порядок: backup → правка → `nginx -t` → `systemctl reload nginx`. + +### 2. Flask — новый endpoint pull (`site/routes/api_bp.py`) +```python +@api_bp.route("/upload_refs", methods=["POST"]) +def upload_refs(): + data = request.get_json(silent=True) or {} + sid = data.get("session") or create_session() + refs = data.get("files") or [] + if not refs: + return jsonify({"ok": False, "error": "No files"}), 400 + added = 0 + with httpx.Client(timeout=120, follow_redirects=True) as client: + for ref in refs: + name, url = ref.get("name"), ref.get("url") + if not name or not url: + continue + content = b"".join(client.stream("GET", url).iter_bytes()) # egress, по частям + if not add_file(sid, name, content): + return jsonify({"ok": False, "error": "Session not found"}), 404 + added += 1 + return jsonify({"ok": True, "session": sid, "count": file_count(sid)}) +``` +- Импорт `httpx` (уже в requirements.txt) и `file_count` из session. +- `stream(...).iter_bytes()` — НЕ `content` целиком (OOM при 100МБ+). +- Обработка ошибок egress (таймаут/403/404) → вернуть 502 с именем файла. + +### 3. Фронт (`site/templates/index.html`, `uploadFiles()`) +Разбить «Фазу 1» на два шага: +- **Шаг 1a:** каждый файл → `fetch(<ВМ>/drhider-upload//, {method:'PUT', body:file})`, + собирать `[{name, size, url}]`. Прогресс — `xhr.upload.onprogress` заменить на `fetch` + + `ReadableStream` (или оставить XHR, но на PUT к ВМ). +- **Шаг 1b:** один `POST /api/upload_refs` с JSON `{session, files:[...]}` (<64КБ). +- Токен: `crypto.randomUUID()`. URL ВМ — константа/`data-атрибут`. +- Показывать прогресс «загрузка на ВМ» отдельно от «загрузка в Flask». + +### 4. `requirements.txt` — без изменений (httpx уже есть). + +## Безопасность и жизненный цикл +- Токен в пути URL — только `crypto.randomUUID()`, не переиспользуется. +- Flask после `add_file` делает `DELETE` на URL ВМ (или ВМ чистит по TTL — cron/systemd-timer + `find /var/www/drhider-upload -mmin +30 -delete`). +- ВМ отдаёт файл только по валидному URL с токеном (нет открытого листинга: `autoindex off`). + +## Грабли (из pattern-дока + drhider) +- CORS: PUT — не simple-метод → браузер шлёт OPTIONS preflight; nginx обязан отвечать 204. +- `site/` конфликтует со stdlib `site.py` (для loadtest; у drhider — Штурвал, без gunicorn). +- egress через `httpx` с `stream`, timeout 120; большие ОТВЕТЫ проходят (проверено 50МБ). +- OOM: не читать файл в память целиком; сессия 500МБ уже ограничивает. +- Локальные curl на Krupski идут через прокси `172.17.192.1:10808` → для теста ВМ `--noproxy '*'`. + +## Порядок работ + проверка +1. ВМ nginx (location + каталог + CORS) → проверить `curl --noproxy '*' -X PUT`. +2. Flask `/api/upload_refs` → проверить `python3 -c "import py_compile; py_compile.compile(...)"`. +3. Фронт `uploadFiles()` → `node -c` нет (это EJS/HTML+JS внутри шаблона) — ручная проверка. +4. Сквозной тест: файл 10МБ → ВМ → Flask → обфускация → ZIP. +5. Commit + push (правило: после правки + bump версии `VERSION` в `site/app.py`). + +## Решения (закрыты) + +- Кластер: сейчас `iot-naeel` (тест), прод — обычный managed. VM-путь обязателен. +- ВМ: одна, `contracts.kube5s.ru` (`5.172.178.213`), общая с loadtest — добавляем location. diff --git a/README.md b/README.md index bf6827d..f2c0fcc 100644 --- a/README.md +++ b/README.md @@ -51,48 +51,31 @@ git add -A && git commit -m "подробное описание" && git push ### `drhider/` — пакет ядра обфускации -| Файл | Назначение | Строк | -|---|---|---| -| `__init__.py` | re-export: `obfuscate_files`, `LLMClient`, `TwoPassObfuscator` | 10 | -| `config.py` | Константы: regex-паттерны (ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN), словари имён/городов/улиц | 80 | -| `checksum.py` | Контрольные суммы: ИНН10, ИНН12, ОГРН | 60 | -| `random_utils.py` | Утилиты: `random_digits`, `random_letters` | 20 | -| `llm_client.py` | LLMClient — HTTP-клиент к LLM API (aillm.ru) | 50 | -| `extractor.py` | Извлечение текста из .docx/.pdf/.txt + `expand_zips` + `convert_pdfs_to_docx` | 180 | -| `scanner.py` | Проход 1: `scan_regex` (regex) + `scan_llm_ner` (LLM NER) | 110 | -| `replacer.py` | Проход 2: `apply_replacements`, `replace_in_docx`, `replace_in_text` | 100 | -| `builder.py` | Сборка: `build_zip`, `build_mapping_csv` | 65 | -| `obfuscator.py` | `TwoPassObfuscator` — оркестратор + `obfuscate_files()` | 100 | - -### `drhider/generators/` — генераторы фиктивных значений - -Каждый файл — один генератор (10–40 строк). Интерфейс: `generate_xxx(original: str) -> str`. - -| Файл | Что генерирует | +| Файл | Назначение | |---|---| -| `phone.py` | +7 (XXX) XXX-XX-XX | -| `email.py` | user@fake-domain | -| `inn.py` | ИНН 10 и 12 знаков (с контрольной суммой) | -| `ogrn.py` | ОГРН 13 знаков | -| `kpp.py` | КПП 9 знаков | -| `bik.py` | БИК 9 знаков (04XXXXXXX) | -| `accounts.py` | Расчётный счёт (40702...) + корр. счёт (30101...) | -| `passport.py` | Паспорт (XX XX XXXXXX) | -| `company.py` | ООО/ЗАО/АО «СлучайноеНазвание» | -| `person.py` | Фамилия И.О. | -| `address.py` | Город, ул. Улица, д. N | +| `__init__.py` | re-export: `obfuscate_files`, `LLMClient`, `TwoPassObfuscator` | +| `config.py` | Константы: regex-паттерны (ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN), словари имён/городов/улиц | +| `llm_client.py` | LLMClient — HTTP-клиент к LLM API (aillm.ru) | +| `extractor.py` | Извлечение текста из .docx/.pdf/.doc/.txt + `expand_zips` | +| `scanner.py` | Проход 1: `scan_regex` (regex) + `scan_llm_ner` (LLM NER) + генерация гибридных токенов | +| `replacer.py` | Проход 2: `apply_replacements`, `replace_in_docx` | +| `builder.py` | Сборка: `build_zip`, `build_mapping_csv` | +| `obfuscator.py` | `TwoPassObfuscator` — оркестратор + `obfuscate_files()` | -`__init__.py` содержит `ENTITY_GENERATORS` — маппинг `entity_type → генератор`. +Фиктивные значения — **гибридные токены** (шаблон + номер), генерируются в `scanner.py` +через `TYPE_POOLS` + `_next_token()`. Примеры: `Иванов_0001`, `ООО_Технология_0034`, `ул_Ленина_0015`. +Отдельного пакета генераторов (`generators/`) нет. ### `site/` — Flask-приложение | Файл | Назначение | |---|---| | `app.py` | `create_app()` — создание Flask, регистрация blueprint'ов, VERSION | +| `session.py` | Управление сессиями: файлы, результат, CSV, очистка по таймауту | | `routes/__init__.py` | `register_routes(app)` | | `routes/main_bp.py` | `GET /` — HTML-интерфейс | | `routes/health_bp.py` | `GET /health` — `{"ok": true, "version": "..."}` | -| `routes/api_bp.py` | `POST /api/drhider` — multipart files → ZIP | +| `routes/api_bp.py` | API загрузки/обработки/скачивания (см. раздел API) | | `templates/index.html` | UI (выбор файлов, прогресс, скачивание) | | `static/favicon.svg` | Иконка | @@ -116,8 +99,12 @@ git add -A && git commit -m "подробное описание" && git push | Метод | Путь | Что делает | |---|---|---| | `GET` | `/` | HTML-интерфейс | -| `GET` | `/health` | `{"ok": true, "version": "2.0.0"}` | -| `POST` | `/api/drhider` | multipart/form-data (поле `files`) → `application/zip` | +| `GET` | `/health` | `{"ok": true, "version": "..."}` | +| `POST` | `/api/upload` | Загрузка файлов в сессию → `{ok, session, count}` | +| `GET` | `/api/process_stream/` | SSE: обработка файлов сессии, прогресс пофайлово | +| `POST` | `/api/process/` | Обработка файлов сессии (legacy) | +| `GET` | `/api/download/` | Скачать ZIP с обфусцированными файлами | +| `GET` | `/api/csv/` | Скачать mapping.csv отдельно | --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8722d36..9c84235 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,7 @@ site/app.py ──→ Flask (create_app) │ ├── GET / → main_bp → templates/index.html ├── GET /health → health_bp → {"ok": true} - └── POST /api/drhider → api_bp → drhider.obfuscate_files() + └── POST /api/upload (→ /api/process_stream/) → api_bp → drhider.obfuscate_files() │ ▼ TwoPassObfuscator @@ -45,7 +45,7 @@ site/app.py ──→ Flask (create_app) | **`.pdf`** | → Markdown | Текст + таблицы (`pdfplumber`), без форматирования | | **`.txt`** и прочие текстовые | → как есть | Декодируется UTF-8, ошибки заменяются `�` | | **`.zip`** | → распаковка | Файлы внутри обрабатываются рекурсивно. Защита от ZIP-бомб: ≤500 файлов, ratio ≤100:1, ≤500 MB | -| **`.doc`** (бинарный) | ❌ не поддерживается | Штурвал не даёт установить LibreOffice. Если понадобится — просить админов платформы | +| **`.doc`** (бинарный) | → Markdown | Через LibreOffice headless: `.doc` → `.docx` → штатный `docx_to_markdown` | Ограничение на размер загрузки: **200 MB** (`MAX_CONTENT_LENGTH`). @@ -56,16 +56,17 @@ site/app.py ──→ Flask (create_app) ### Проход 1: сбор сущностей ``` -Файлы (.docx, .pdf, .txt, .zip) +Файлы (.docx, .pdf, .doc, .txt, .zip) │ ▼ extractor.expand_zips() ← распаковать ZIP │ ▼ -extractor.convert_pdfs_to_docx() ← PDF → DOCX - │ - ▼ extractor.extract_text() ← извлечь текст из каждого файла + │ (.docx → docx_to_markdown, + │ .pdf → pdf_to_markdown, + │ .doc → doc_to_markdown, + │ .txt → как есть) │ ├──→ scanner.scan_regex() ← regex: телефоны, email, ИНН, ОГРН, КПП, │ БИК, счета, паспорта, компании, ФИО @@ -74,7 +75,7 @@ extractor.extract_text() ← извлечь текст из каждог (только если llm_client передан) │ ▼ -mapping = {оригинал → фиктивное} ← глобальный словарь замен +mapping = {оригинал → токен} ← глобальный словарь замен (гибридные токены) ``` ### Проход 2: замена @@ -86,8 +87,7 @@ mapping + sorted_keys (по убыванию длины) Для каждого файла: │ ├── .docx → replacer.replace_in_docx() ← склеить runs → заменить → сохранить - ├── .doc → без изменений (бинарный) - └── .txt/.pdf → replacer.replace_in_text() ← простая строковая замена + └── остальные → replacer.apply_replacements() ← замена в тексте (Markdown/plain) │ ▼ builder.build_mapping_csv() ← mapping.csv @@ -104,44 +104,33 @@ builder.build_zip() ← ZIP со всеми файлами + mappi - `COMPANY_PATTERN`, `PERSON_PATTERN` — скомпилированные regex - Словари: `RU_SURNAMES`, `RU_NAMES`, `RU_PATRONYMICS`, `RU_CITIES`, `RU_STREETS`, `FAKE_DOMAINS` -### `drhider/checksum.py` -Чистые функции для расчёта контрольных сумм. Без побочных эффектов. -- `checksum_inn10(inn)` → 1 цифра -- `checksum_inn12(inn)` → 2 цифры -- `checksum_ogrn(ogrn)` → 1 цифра - -### `drhider/random_utils.py` -Утилиты генерации случайных строк. -- `random_digits(n)` → строка из n цифр -- `random_letters(n)` → строка из n строчных латинских букв - -### `drhider/generators/` -Каждый генератор — независимая функция с сигнатурой `generate_xxx(original: str) -> str`. -Параметр `original` нужен для извлечения префикса (например, «ИНН » из «ИНН 1234567890»). -Генераторы НЕ общаются друг с другом — только импортируют из `config`, `checksum`, `random_utils`. - -`__init__.py` содержит `ENTITY_GENERATORS` — словарь, связывающий `entity_type` из `ENTITY_PATTERNS` с функцией-генератором. Используется в `scanner.scan_regex()`. - ### `drhider/llm_client.py` HTTP-клиент к OpenAI-совместимому API. Читает переменные окружения. Интерфейс: `.complete(prompt: str) -> str`. ### `drhider/extractor.py` -Три независимые функции (не класс): -- `extract_text(fname, content, ctype)` → `(text, docx_document_or_None)` +Функции (не класс): +- `docx_to_markdown(content)` → str (Markdown) +- `pdf_to_markdown(content)` → str (Markdown) +- `doc_to_markdown(content)` → str (через LibreOffice headless) +- `extract_text(fname, content, ctype)` → str - `expand_zips(files)` → распакованный список -- `convert_pdfs_to_docx(files)` → PDF заменены на DOCX ### `drhider/scanner.py` -Две функции (не класс), мутируют переданный `mapping: Dict[str, str]`: -- `scan_regex(text, mapping)` — regex-поиск +Функции (не класс), мутируют переданные `mapping` и `counters`: +- `TYPE_POOLS`, `_next_token(entity_type, counters)` — генерация гибридных токенов (шаблон + номер) +- `scan_regex(text, mapping, counters)` — regex-поиск +- `split_into_chunks(text, size, overlap)` — разбиение текста на чанки для LLM +- `normalize_entity(s)` — нормализация сущности перед дедупом - `scan_llm_ner(all_texts, mapping, llm_client)` — LLM NER +Фиктивные значения — гибридные токены (`Иванов_0001`, `ООО_Технология_0034`), а не +отдельные функции-генераторы на каждый тип. Отдельного пакета `generators/` нет. + ### `drhider/replacer.py` -Три чистые функции: +Функции: - `apply_replacements(text, mapping, sorted_keys)` → строка с заменами - `replace_in_docx(doc, mapping, sorted_keys)` → bytes (изменённый DOCX) -- `replace_in_text(text, fname, mapping, sorted_keys)` → bytes ### `drhider/builder.py` Две функции сборки результата: @@ -161,7 +150,8 @@ HTTP-клиент к OpenAI-совместимому API. Читает пере Blueprint'ы — каждый в своём файле: - `main_bp` — только `GET /` - `health_bp` — только `GET /health` -- `api_bp` — только `POST /api/drhider` +- `api_bp` — `POST /api/upload`, `GET /api/process_stream/` (SSE), + `POST /api/process/`, `GET /api/download/`, `GET /api/csv/` `__init__.py` содержит `register_routes(app)` — единая точка регистрации. @@ -170,14 +160,20 @@ Blueprint'ы — каждый в своём файле: ## Поток данных ``` -HTTP POST /api/drhider (multipart/form-data) +1. POST /api/upload (multipart/form-data) + │ request.files.getlist("files") + │ → session.add_file(...) для каждого файла + ▼ + JSON {ok, session: , count} + +2. GET /api/process_stream/ (SSE) │ ▼ -api_bp.drhider() - │ request.files.getlist("files") - │ → [(filename, bytes, mimetype), ...] +api_bp.process_stream(sid) + │ worker-поток: + │ → [(filename, bytes, ""), ...] из сессии ▼ -obfuscate_files(files, llm_client=LLMClient()) +obfuscate_files(files, llm_client=LLMClient(), progress_cb=...) │ Scanner → Extractor → LLM NER → Obfuscator → Replacer → Builder ▼ bytes (ZIP-архив с обезличенными документами + mapping.csv) @@ -284,9 +280,9 @@ BOM (`\ufeff`) нужен для корректного открытия CSV в ▼ TwoPassObfuscator.obfuscate(files) │ - ├── expand_zips → convert_pdfs_to_docx → extract_text + ├── expand_zips → extract_text ├── scan_regex + scan_llm_ner → mapping - ├── replace_in_docx / replace_in_text → обфусцированные файлы + ├── replace_in_docx / apply_replacements → обфусцированные файлы └── build_mapping_csv + build_zip → (zip_bytes, csv_str) │ ▼