docs: актуализация README/ARCHITECTURE + план паттерна ВМ-буфер+pull (снимок перед ВМ-инкапсуляцией загрузки)
Deploy drhider / validate (push) Canceled after 0s

This commit is contained in:
“Naeel”
2026-08-21 15:35:45 +03:00
parent a9d4139afb
commit 705b39ab7d
3 changed files with 182 additions and 75 deletions
@@ -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/<token>/<name>, {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.
+20 -33
View File
@@ -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/<sid>` | SSE: обработка файлов сессии, прогресс пофайлово |
| `POST` | `/api/process/<sid>` | Обработка файлов сессии (legacy) |
| `GET` | `/api/download/<sid>` | Скачать ZIP с обфусцированными файлами |
| `GET` | `/api/csv/<sid>` | Скачать mapping.csv отдельно |
---
+38 -42
View File
@@ -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/<sid>) → 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/<sid>` (SSE),
`POST /api/process/<sid>`, `GET /api/download/<sid>`, `GET /api/csv/<sid>`
`__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: <sid>, count}
2. GET /api/process_stream/<sid> (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)