142 Commits
Author SHA1 Message Date
naeel 301ceecaa8 docs: v0.0.30-31 — .doc поддержка, обновление версии
Deploy drhider / validate (push) Has been cancelled
2026-07-16 12:24:47 +04:00
naeel cb6274a887 fix: .doc теперь обрабатывается (обфускация + md в архиве)
Deploy drhider / validate (push) Waiting to run
2026-07-16 12:16:54 +04:00
naeel 87a6df45c5 v0.0.30: .doc поддержка — accept + liberta API вместо LibreOffice
Deploy drhider / validate (push) Waiting to run
2026-07-16 11:28:16 +04:00
naeel c102d54887 chore: gitignore + clean
Deploy drhider / validate (push) Waiting to run
2026-07-16 06:54:06 +04:00
naeel a182a2f38a docs: MIGRATION-GUIDE — дополнен фиксами из v0.0.27-29 (fetch, dedup, Blob download)
Deploy drhider / validate (push) Waiting to run
2026-07-16 06:53:19 +04:00
naeel 5f0ca8e463 docs: PROBLEM-AND-SOLUTION.md — переписан, только факты, без ложных следов
Deploy drhider / validate (push) Waiting to run
2026-07-16 06:50:29 +04:00
naeel 8484197eec docs: финальный диагноз — keep-alive 10 единственная причина RST
Deploy drhider / validate (push) Waiting to run
2026-07-15 19:15:17 +04:00
naeel 6e8f79ac56 docs: итоговая диагностика RST — hostPort vs cloud LB, корневая причина
Deploy drhider / validate (push) Waiting to run
2026-07-15 19:09:24 +04:00
naeel 2aea7bf2f6 fix: move cluster dump TMP → History
Deploy drhider / validate (push) Waiting to run
2026-07-15 18:46:35 +04:00
naeel 0ff904ceb6 cluster dump 2026-07-15: ingress, kube-vip, drhider state
Deploy drhider / validate (push) Waiting to run
2026-07-15 18:34:43 +04:00
naeel fd4f152fc8 chore: убрал большие файлы из git
Deploy drhider / validate (push) Waiting to run
2026-07-15 15:18:25 +04:00
naeel d205118cd5 chore: всё закоммичено — TMP, gitignore, chrome log 2026-07-15 15:15:06 +04:00
naeel 4af247938f docs: полное состояние проекта на 2026-07-15
Deploy drhider / validate (push) Waiting to run
2026-07-15 15:14:20 +04:00
naeel 0640e7d47f docs: формат имён скачиваемых файлов в архитектуру
Deploy drhider / validate (push) Waiting to run
2026-07-15 15:11:11 +04:00
naeel 5efed1c577 fix: имя файла из Content-Disposition а не хардкод
Deploy drhider / validate (push) Waiting to run
2026-07-15 15:06:43 +04:00
naeel 40372fe03f fix: Выбрать файлы добавляет а не стирает таблицу + dedup
Deploy drhider / validate (push) Waiting to run
2026-07-15 15:01:35 +04:00
naeel e66dff23b8 fix: download ZIP/CSV через fetch+Blob вместо window.location — не сбрасывает страницу
Deploy drhider / validate (push) Waiting to run
2026-07-15 14:57:03 +04:00
naeel 68fada075e chore: финальная документация — всё закоммичено
Deploy drhider / validate (push) Waiting to run
2026-07-14 22:02:25 +04:00
naeel 338cb2f372 docs: инструкция миграции UI и загрузки с ВМ на Flask (для Сверки Контрактов)
Deploy drhider / validate (push) Waiting to run
2026-07-14 22:01:43 +04:00
naeel 9b4060d8c5 docs: требования к Flask-приложению для Штурвала — health, no_cache, SSE, BOM, etc
Deploy drhider / validate (push) Waiting to run
2026-07-14 21:58:56 +04:00
naeel 8158a8bc01 docs: полная конфигурация кластера — MTU + keep-alive + HTTP/2 + RST fix
Deploy drhider / validate (push) Waiting to run
2026-07-14 21:54:23 +04:00
naeel 26572abb3b fix: JS warmup GET /health на загрузке + keep-alive 75 + bump v0.0.26
Deploy drhider / validate (push) Waiting to run
2026-07-14 20:40:26 +04:00
naeel cd29e727f3 fix: uploadFiles не теряет файлы после resetAll — сохраняет sf.slice()
Deploy drhider / validate (push) Waiting to run
2026-07-14 19:31:44 +04:00
naeel e513bf3c9e fix: es → activeES во всех местах (был не заменён на строке 262)
Deploy drhider / validate (push) Waiting to run
2026-07-14 19:29:29 +04:00
naeel 17bc4aebf8 fix: сброс состояния при F5/закрытии — beforeunload, abort XHR, close EventSource
Deploy drhider / validate (push) Waiting to run
2026-07-14 19:22:36 +04:00
naeel b3298b415e docs: PROBLEM-AND-SOLUTION, правила, история Connection:close, gitignore
Deploy drhider / validate (push) Waiting to run
2026-07-14 19:19:12 +04:00
naeel 5e7319cd42 fix: убрать Connection: close — Waitress/WSGI запрещает hop-by-hop заголовки
Deploy drhider / validate (push) Waiting to run
2026-07-14 13:28:00 +04:00
naeel 204e865aff docs: исправлен PROBLEM-AND-SOLUTION.md — mtu 1400 а не 1450, убран раздел Опуса, обновлены результаты
Deploy drhider / validate (push) Waiting to run
2026-07-14 13:27:06 +04:00
naeel 6fe1ba2b42 fix: Connection: close на всех ответах — nginx не кэширует upstream keepalive
Deploy drhider / validate (push) Waiting to run
2026-07-14 13:21:31 +04:00
naeel 0ceaee1089 fix: XHR + cache-busting ?_=Date.now() чтобы исключить connection reuse
Deploy drhider / validate (push) Waiting to run
2026-07-14 10:31:26 +04:00
naeel b6dac6f2bd fix: XHR timeout 30с + ontimeout — не висеть бесконечно на загрузке
Deploy drhider / validate (push) Waiting to run
2026-07-14 10:24:55 +04:00
naeel 79cd25a762 fix: SSE — отлов GeneratorExit при отключении клиента
Deploy drhider / validate (push) Waiting to run
- api_bp.py: try/except GeneratorExit после каждого yield в generate()
- при F5 во время обработки поток освобождается, остальные файлы не обрабатываются
- bump: 0.0.17 → 0.0.18
2026-07-14 09:15:44 +04:00
naeel bbffb00eb8 fix: убрать .doc из UI и документации (LibreOffice недоступен на Штурвале)
Deploy drhider / validate (push) Waiting to run
2026-07-14 08:30:37 +04:00
naeel ddbf67924b docs: добавить раздел Kubernetes-кластер в DEPLOY.md (kubectl, ingress, cilium)
Deploy drhider / validate (push) Waiting to run
2026-07-14 07:42:32 +04:00
naeel 00d54dbfb6 feat: поддержка .doc через LibreOffice headless
Deploy drhider / validate (push) Waiting to run
- extractor.py: добавлена doc_to_markdown() — .doc → .docx (soffice) → docx_to_markdown()
- Dockerfile: apt install libreoffice-writer
- ARCHITECTURE.md: обновлён статус .doc
- bump: 0.0.16 → 0.0.17
2026-07-14 07:21:39 +04:00
naeel 444ef4b732 docs: добавить раздел «Поддерживаемые форматы» в ARCHITECTURE.md
Deploy drhider / validate (push) Waiting to run
2026-07-14 07:04:42 +04:00
naeel f072fc9fdb docs: уточнение — реконсил по событию, не по таймеру
Deploy drhider / validate (push) Waiting to run
2026-07-13 22:18:03 +04:00
naeel f38ba4ca37 docs: финальный обзор Опуса + правки (Ethernet, 51с)
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:57:25 +04:00
naeel 82b5cb4402 docs: схема провайдерской инкапсуляции + таблица параметров
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:53:15 +04:00
naeel ec9107cff4 docs: eth0=1500, bottleneck в сети провайдера, 1450 empirically
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:49:36 +04:00
naeel fe45b07c4f docs: ping DF блокирован файрволлом, 1450 подтверждён эмпирически
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:48:00 +04:00
naeel cd22fd62a6 docs: исправлены ошибки (TCP backoff, DSR, ping) + ответ Опуса
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:45:44 +04:00
naeel 5e4418f648 docs: убраны домыслы — причина 1450 неизвестна, факт установлен замером
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:38:02 +04:00
naeel 2949d0ef09 docs: пояснение — почему Cilium ожидает 1500
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:36:21 +04:00
naeel 1ad35dada7 docs: flowchart — Geneve над данными через ---
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:34:55 +04:00
naeel ae09c23d81 docs: Geneve над данными, без жаргона
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:33:04 +04:00
naeel d0f12ef4a3 docs: горизонт + 50 над 1450 в subgraph
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:30:59 +04:00
naeel a34a78d8b9 docs: вертикальная схема — шапка сверху, тело снизу, дверь не пускает
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:29:38 +04:00
naeel ab08acd60d docs: Mermaid — пакет + шапка = не влазит в дверь
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:28:12 +04:00
naeel f005e8cf29 docs: простая ASCII-схема — рост пакета vs дверь
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:26:41 +04:00
naeel 1da496c2e5 docs: схема яснее — один путь от А к Б через Geneve
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:25:13 +04:00
naeel 17fc2670a5 docs: Mermaid flowchart вместо block-beta
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:22:28 +04:00
naeel 7ed1f5d650 docs: Mermaid-диаграмма вместо ASCII
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:21:19 +04:00
naeel 3df63df0b0 docs: ASCII-схема пакета Geneve и MTU
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:20:28 +04:00
naeel 75568a2586 docs: пояснение Cilium и Geneve курсивом перед списком
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:18:02 +04:00
naeel 9ea3c2991a docs: причина расписана по шагам с жирными числами
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:15:48 +04:00
naeel cbf7de27d8 docs: результаты в тестовом кластере + рекомендации для production
Deploy drhider / validate (push) Waiting to run
2026-07-13 21:06:19 +04:00
naeel d94fa3f556 v0.0.16: Nubes логотип, favicon, версия слева, модальное окно Help
Deploy drhider / validate (push) Waiting to run
2026-07-13 18:11:52 +04:00
naeel 7d39885d90 docs: проблема описана обще — HTTP POST на managed Flask через ingress
Deploy drhider / validate (push) Waiting to run
2026-07-13 18:02:38 +04:00
naeel e7da82538f docs: безличный стиль — «были применены» вместо «мы»
Deploy drhider / validate (push) Waiting to run
2026-07-13 18:00:27 +04:00
naeel a91a5e9538 docs: уточнено — что именно сделано сейчас (Local + 4 реплики)
Deploy drhider / validate (push) Waiting to run
2026-07-13 17:55:21 +04:00
naeel c25b22cfe6 docs: PROBLEM-AND-SOLUTION v2 — полный анализ Опуса 4.8
Deploy drhider / validate (push) Waiting to run
2026-07-13 17:49:42 +04:00
naeel 68571fbbcd docs: PROBLEM-AND-SOLUTION — корневой .md для девопса
Deploy drhider / validate (push) Waiting to run
2026-07-13 17:45:36 +04:00
naeel eaef850421 docs: замер MTU — confirmed underlay=1450, tunnel=1400
Deploy drhider / validate (push) Waiting to run
2026-07-13 17:42:03 +04:00
naeel 0950b68649 docs: MSS-CLAMPING-PLAN v2 — исправлено после валидации Opus (underlay MTU, замер, рестарт)
Deploy drhider / validate (push) Waiting to run
2026-07-13 17:29:12 +04:00
naeel 322626a372 docs: запрос Opus — валидация MSS Clamping плана для девопса
Deploy drhider / validate (push) Waiting to run
2026-07-13 17:22:54 +04:00
naeel f9b88cbf93 v0.0.15: fix _md_tolerant_pattern — всегда, не только при промахе
Deploy drhider / validate (push) Waiting to run
2026-07-13 17:18:00 +04:00
naeel a56a819329 v0.0.14: MSK time (UTC+3) — ZIP entries + download filenames
Deploy drhider / validate (push) Waiting to run
2026-07-13 17:05:16 +04:00
naeel f3c854c5fb v0.0.13: fix Markdown-bold splitting + \xa0 in PERSON_PATTERN
Deploy drhider / validate (push) Waiting to run
2026-07-13 13:18:10 +04:00
naeel 23b4ad9889 docs: ответ Sonnet — фикс Markdown-жирности и \xa0 в PERSON_PATTERN
Deploy drhider / validate (push) Waiting to run
2026-07-13 13:14:42 +04:00
naeel 29e2d08cdb docs: MSS-CLAMPING-PLAN — корневое решение вместо Local+4реплики
Deploy drhider / validate (push) Waiting to run
2026-07-13 13:12:55 +04:00
naeel 0cdef67c1f docs: ответ Sonnet — TMP-файлы от старого кода, _next_token() чист
Deploy drhider / validate (push) Waiting to run
2026-07-13 12:41:08 +04:00
naeel 8c59c4b7e0 docs: запрос Sonnet — «токены» заменены на «коды замены»
Deploy drhider / validate (push) Waiting to run
2026-07-13 12:35:16 +04:00
naeel 70226d8c39 docs: запрос Sonnet — анализ токенов обфускации
Deploy drhider / validate (push) Waiting to run
2026-07-13 12:33:03 +04:00
naeel 4a4de0f8e2 docs: INGRESS-SOLUTIONS — добавлен Opus анализ + helm upgrade команды
Deploy drhider / validate (push) Waiting to run
2026-07-13 11:55:36 +04:00
naeel 38109ba6ec docs: INGRESS-SOLUTIONS — Sonnet анализ вариантов DaemonSet/MTU/DSR
Deploy drhider / validate (push) Waiting to run
2026-07-13 11:53:47 +04:00
naeel adab2c624f v0.0.12: fix download — window.location вместо a.click()
Deploy drhider / validate (push) Waiting to run
2026-07-13 11:44:43 +04:00
naeel a380c0e70c docs: KUBEVIP — Helm auto-reset root cause, updated pod list
Deploy drhider / validate (push) Waiting to run
2026-07-13 11:39:50 +04:00
naeel bbc45e155f v0.0.11: fix download — a.appendTo(body) перед click
Deploy drhider / validate (push) Waiting to run
2026-07-13 11:13:28 +04:00
naeel 7bdcb5b7b4 v0.0.10: fix download filename — timestamp локальное время
Deploy drhider / validate (push) Waiting to run
2026-07-13 11:09:20 +04:00
naeel c878b3db1a v0.0.9: per-file timer — 3.2с → ✓ 12.5с
Deploy drhider / validate (push) Waiting to run
2026-07-13 11:06:52 +04:00
naeel bf202cdfa4 v0.0.8: fix SSE format — event: строка отдельно от data:
Deploy drhider / validate (push) Waiting to run
2026-07-13 10:59:03 +04:00
naeel e3405556b5 v0.0.7: SSE per-file progress — таблица обновляется по каждому файлу
Deploy drhider / validate (push) Waiting to run
2026-07-13 10:55:43 +04:00
naeel 46d8ced0dd v0.0.6: прогресс загрузки в строке (XMLHttpRequest onprogress)
Deploy drhider / validate (push) Waiting to run
2026-07-13 10:52:09 +04:00
naeel e4975fb37e v0.0.5: CSV отдельно, timestamp имена, прогресс в таблице
Deploy drhider / validate (push) Waiting to run
2026-07-13 10:40:00 +04:00
naeel 613b645456 v0.0.4: fix — restore index.html (accidentally deleted)
Deploy drhider / validate (push) Waiting to run
2026-07-13 10:33:56 +04:00
naeel b7075dd8bf docs: KUBEVIP-EXTERNAL-TRAFFIC-POLICY — ingress replicas fix
Deploy drhider / validate (push) Waiting to run
2026-07-13 10:33:30 +04:00
naeel 75c7424963 v0.0.3: таблица + кнопка, без JS
Deploy drhider / validate (push) Waiting to run
2026-07-13 10:14:22 +04:00
naeel 0d16bb6520 v0.0.2: minimal Hello World — только шапка Nubes + версия
Deploy drhider / validate (push) Waiting to run
2026-07-13 10:10:06 +04:00
naeel 491b49f633 v0.0.1: revert to v7.0.0 code — api_bp unified, no Connection close, version reset
Deploy drhider / validate (push) Waiting to run
2026-07-13 10:04:15 +04:00
naeel 76a2d4b7ed v0.0.3: fix Connection header banned by WSGI PEP 3333
Deploy drhider / validate (push) Waiting to run
2026-07-13 09:25:07 +04:00
naeel c35e8da357 v0.0.2: split api_bp into upload/process/download modules, Connection: close, upload tests (13 new, 106 total)
Deploy drhider / validate (push) Waiting to run
2026-07-13 09:15:38 +04:00
naeel 345448caa4 reset version 0.0.1
Deploy drhider / validate (push) Waiting to run
2026-07-13 08:45:52 +04:00
naeel 18922b486b v7.0.0: API upload/process/download, session.py, фронт без обработки — всё на бэкенде
Deploy drhider / validate (push) Waiting to run
2026-07-13 08:12:20 +04:00
naeel e590d8ab9a v6.0.0: вся обработка на бэкенде — zip.js удалён, фронт только XHR+скачивание
Deploy drhider / validate (push) Waiting to run
2026-07-13 07:53:08 +04:00
naeel cbf619aaba docs: принципы 6-7 — вся обработка на бэкенде, пофайлово, фронт только UI
Deploy drhider / validate (push) Waiting to run
2026-07-13 07:48:19 +04:00
naeel 0998892d7f v5.2.1: Dockerfile как у шаблона Baldurs-Gate (CMD python site/app.py) — без него ingress дропает соединения
Deploy drhider / validate (push) Waiting to run
2026-07-13 07:31:28 +04:00
naeel d0e874d87e v5.2.0: waitress вместо app.run() — production WSGI, 8 потоков, исправляет ERR_TIMED_OUT
Deploy drhider / validate (push) Waiting to run
2026-07-12 16:05:44 +04:00
naeel 81bdee6203 v5.1.0: тесты builder(20), extractor(15), scanner(20), replacer(10) — 65 тестов, 0 ошибок
Deploy drhider / validate (push) Waiting to run
2026-07-12 15:31:42 +04:00
naeel efaa42dd2e v5.0.9: ZIP модуль вынесен в site/static/zip.js, тесты test_zip.py (28 тестов), исправлен баг смещения start+18
Deploy drhider / validate (push) Waiting to run
2026-07-12 15:25:39 +04:00
naeel 9145345882 fix: баг в unzip() — неверное смещение compressed size (+14 вместо start+18)
Deploy drhider / validate (push) Waiting to run
2026-07-12 15:01:33 +04:00
naeel ee42865fcd fix: убран Connection: close — каждый статик-файл требовал новый TCP handshake
Deploy drhider / validate (push) Waiting to run
2026-07-12 14:55:16 +04:00
naeel 3ba201748c минимизация copilot-instructions.md (3 строки) + CODERULES.md отдельно
Deploy drhider / validate (push) Waiting to run
2026-07-12 14:53:38 +04:00
naeel f98b4ba7a5 fix: убрал правило из copilot-instructions.md (экономия токенов), оставил в ARCHITECTURE.md
Deploy drhider / validate (push) Waiting to run
2026-07-12 14:51:40 +04:00
naeel a1574677a5 docs: правило «ноль внешних зависимостей» в ARCHITECTURE.md и copilot-instructions.md
Deploy drhider / validate (push) Waiting to run
2026-07-12 14:50:42 +04:00
naeel 1a84770e82 v5.0.2: нативный ZIP (unzip + buildZip) — 0 внешних зависимостей, страница грузится мгновенно
Deploy drhider / validate (push) Waiting to run
2026-07-12 14:48:59 +04:00
naeel 75b8476703 fix: JSZip локально вместо CDN — убирает задержку 10с
Deploy drhider / validate (push) Waiting to run
2026-07-12 14:42:25 +04:00
naeel 370a869aef v5.0.0: гибридные токены (Иванов_0001, ООО_Технология_0034), удалены generators/, checksum, random_utils
Deploy drhider / validate (push) Waiting to run
2026-07-12 14:30:12 +04:00
naeel 62c7ea0162 v4.0.2: бегущий секундомер в колонке статуса ( 5с, 6с...)
Deploy drhider / validate (push) Waiting to run
2026-07-12 14:19:44 +04:00
naeel df15f48ecc fix: Connection: close в after_request — XHR не держат keep-alive после запросов
Deploy drhider / validate (push) Waiting to run
2026-07-12 14:12:43 +04:00
naeel adf3002e3d v4.0.0: дизайн Nubes, логотип, ZIP без mapping.csv внутри, CSV отдельно с датой-временем
Deploy drhider / validate (push) Waiting to run
2026-07-12 13:52:24 +04:00
naeel 59f7601266 fix: no-cache заголовки (Cache-Control, Pragma, Expires) — F5 больше не кэширует
Deploy drhider / validate (push) Waiting to run
2026-07-12 13:11:48 +04:00
naeel f15af2866d fix: дата/время файлов в ZIP (time.localtime), bump 3.2.3
Deploy drhider / validate (push) Waiting to run
2026-07-12 13:07:07 +04:00
naeel 1d91d5f92e bump 3.2.1 → 3.2.2
Deploy drhider / validate (push) Waiting to run
2026-07-12 12:59:16 +04:00
naeel fd5e1613a5 fix: ссылки в copilot-instructions.md (../ вместо ./)
Deploy drhider / validate (push) Waiting to run
2026-07-12 12:58:29 +04:00
naeel 303830528f bump 3.2.0 → 3.2.1
Deploy drhider / validate (push) Waiting to run
2026-07-12 12:56:39 +04:00
naeel 3d55562f02 fix: безопасный re.match в генераторах + сброс fileInput при загрузке страницы
Deploy drhider / validate (push) Waiting to run
2026-07-12 12:54:40 +04:00
naeel dacf0d584f v3.2.0: новый LLM-промпт (STEP 1/2, фикс. типы, NOT PII, already_found), убран хардкод НУБЕС
Deploy drhider / validate (push) Waiting to run
2026-07-12 11:14:26 +04:00
naeel 5c426cb037 v3.2.0: новый LLM-промпт (STEP 1/2, фикс. типы, NOT PII, already_found), убран хардкод НУБЕС
Deploy drhider / validate (push) Waiting to run
2026-07-12 11:11:43 +04:00
naeel 0c2b636d31 v3.1.0: пофайловая обработка, прогресс в таблице, JSZip — один ZIP на выходе
Deploy drhider / validate (push) Waiting to run
2026-07-12 10:50:51 +04:00
naeel a87361c159 bump version 3.0.1 → 3.0.2
Deploy drhider / validate (push) Waiting to run
2026-07-12 10:33:54 +04:00
naeel 7fc41aabce frontend: пофайловая загрузка с прогрессом, убран gunicorn
Deploy drhider / validate (push) Waiting to run
2026-07-12 10:33:09 +04:00
naeel 2731bc7f09 fix: вернул gunicorn+gevent — без них Flask dev server не держит долгие LLM-запросы
Deploy drhider / validate (push) Waiting to run
2026-07-12 10:18:04 +04:00
naeel 724124b3fc fix: убрал тестовый файл из репы, добавил в .gitignore
Deploy drhider / validate (push) Waiting to run
2026-07-12 10:17:01 +04:00
naeel 72ae915935 fix: app.run(threaded=True) для долгих запросов
Deploy drhider / validate (push) Waiting to run
2026-07-12 10:16:52 +04:00
naeel 3e5aef70a0 fix: добавлен app.run() — Штурвал запускает python app.py, нужен listen на :5000
Deploy drhider / validate (push) Waiting to run
2026-07-12 09:43:34 +04:00
naeel 34f9a6f74f fix: импорт routes в app.py (site конфликтует со stdlib, нельзя from .routes)
Deploy drhider / validate (push) Waiting to run
2026-07-12 09:29:59 +04:00
naeel 84ae7439f5 History: v3.0.0 завершён
Deploy drhider / validate (push) Waiting to run
2026-07-12 09:15:13 +04:00
naeel 2a2cc1467c v3.0.0: универсальная архитектура — MD-пайплайн, LLM сама определяет типы, fallback-генератор
Deploy drhider / validate (push) Waiting to run
2026-07-12 09:14:45 +04:00
naeel f5427b0db7 replacer.py: удалён replace_in_text (заменён на apply_replacements напрямую)
Deploy drhider / validate (push) Waiting to run
2026-07-12 09:14:22 +04:00
naeel af2f2a9f71 extractor.py → MD-пайплайн (docx_to_markdown, pdf_to_markdown), obfuscator.py — новый пайплайн без docx-объектов
Deploy drhider / validate (push) Waiting to run
2026-07-12 09:13:47 +04:00
naeel 2ceeb05a8b scanner.py: универсальный LLM-промпт (LLM сама определяет типы) + fallback-генератор
Deploy drhider / validate (push) Waiting to run
2026-07-12 09:11:29 +04:00
naeel 579f9c8334 generators: fallback.py — character-class preserving для неизвестных типов
Deploy drhider / validate (push) Waiting to run
2026-07-12 09:09:54 +04:00
naeel d607d7d306 Организация: .md файлы → docs/, обновлены ссылки
Deploy drhider / validate (push) Waiting to run
2026-07-12 08:46:12 +04:00
naeel 4c3f9d4e49 Разделение: copilot-instructions.md (правила) + DEVELOPMENT.md (порядок правок)
Deploy drhider / validate (push) Waiting to run
2026-07-12 08:43:42 +04:00
naeel cc2f627de2 History: документация 2026-07-12
Deploy drhider / validate (push) Waiting to run
2026-07-12 08:38:07 +04:00
naeel 0707d53b37 Документация: README.md, ARCHITECTURE.md, DEPLOY.md, .github/copilot-instructions.md
Deploy drhider / validate (push) Waiting to run
2026-07-12 08:37:51 +04:00
naeel 7a005f7b06 History: результаты рефакторинга
Deploy drhider / validate (push) Waiting to run
2026-07-12 08:34:40 +04:00
naeel 51bc1a79e7 Фаза 2: site/app.py → blueprint'ы (main, health, api), старый drhider.py удалён
Deploy drhider / validate (push) Waiting to run
2026-07-12 08:34:06 +04:00
naeel 2cf4e08100 Фаза 2: llm_client.py, extractor.py, scanner.py, replacer.py, builder.py, obfuscator.py, __init__.py
Deploy drhider / validate (push) Waiting to run
2026-07-12 08:24:33 +04:00
naeel 37581eb601 Фаза 2: config.py, checksum.py, random_utils.py, generators/ (11 модулей)
Deploy drhider / validate (push) Waiting to run
2026-07-12 08:22:20 +04:00
naeel a2f754ab4f Фаза 1: удалён Dockerfile, drhider_server.py, gunicorn из requirements, __main__ блок
Deploy drhider / validate (push) Waiting to run
2026-07-12 08:19:45 +04:00
84 changed files with 6980 additions and 984 deletions
+24
View File
@@ -0,0 +1,24 @@
# ⛔ ЖЁСТКИЕ ПРАВИЛА
- НЕЛЬЗЯ менять код без «делай» — показать план → ждать
- НЕЛЬЗЯ менять ЧТО-ЛИБО при вопросе (?, почему, как, где, что) — только ответ словами
- НЕЛЬЗЯ спешить — обдумать, проверить, потом отвечать
- НЕЛЬЗЯ лезть в /home/naeel/nubes/contracts/ (кроме loadtest)
- НЕЛЬЗЯ предполагать — сомневаешься = спроси
- НЕЛЬЗЯ грузить внешние CDN/библиотеки на фронтенд — всё в коде
# ✅ ПОРЯДОК ПРАВКИ
1. План → «делай»
2. Правим → проверяем syntax → проверяем import
3. git add -A && git commit -m "..." && git push
4. Bump VERSION в site/app.py перед push
# 📋 ДОКИ
- Порядок правок: docs/DEVELOPMENT.md
- Архитектура: docs/ARCHITECTURE.md
- Деплой: docs/DEPLOY.md
- История: History/
# 📋 ПРОЕКТ
Штурвал Managed Flask, без Dockerfile/gunicorn, без БД
URL: https://drhider.pythonk8s.dev.nubes.ru/
ВМ legacy: ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213
+4
View File
@@ -0,0 +1,4 @@
# ⛔⛔⛔ STOP: без «делай» ничего не делать
# ⛔⛔⛔ ВОПРОС (?, почему, как, где, что) — ТОЛЬКО ОТВЕТ. Читать/узнавать МОЖНО, менять НЕЛЬЗЯ.
# ⛔ Не спешить. Не лезть в contracts/. Не гадать — спросить.
# 📋 Правила: .github/CODERULES.md
+5
View File
@@ -1,3 +1,8 @@
__pycache__/
*.pyc
.env
# TMP/
# about1500.md
*.har
chrome-net-export-log.json
TSTFILES/
+5 -2
View File
@@ -1,8 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libreoffice-writer \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY drhider.py drhider_server.py /app/
COPY site /app/site
COPY drhider /app/drhider
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--timeout", "300", "--workers", "1", "--worker-class", "gevent", "--limit-request-field_size", "0", "--limit-request-body", "0", "site.app:app"]
CMD ["python", "site/app.py"]
+145
View File
@@ -0,0 +1,145 @@
# DrHider — Полный аудит и план рефакторинга
**Дата:** 2026-07-12
**Платформа:** Штурвал (Managed Flask) — запускает Flask сам, без Dockerfile/gunicorn
**URL:** https://drhider.pythonk8s.dev.nubes.ru/
**Репозиторий:** https://gitea.services.ngcloud.ru/Nail/drhider
---
## Текущее состояние (аудит каждого файла)
| Файл | Статус | Решение |
|---|---|---|
| `Dockerfile` | ❌ НЕ НУЖЕН | Штурвал сам запускает Flask. Удалить. |
| `drhider_server.py` | ❌ НЕ НУЖЕН | Standalone-сервер с ВМ (:8767). Flask app.py уже обрабатывает /api/drhider. Удалить. |
| `requirements.txt` | ⚠️ Нужны правки | Убрать `gunicorn` и `gevent`. Оставить `flask`, `python-docx`, `pdfplumber`, `httpx`. |
| `.gitignore` | ✅ OK | Без изменений. |
| `.gitea/workflows/deploy.yaml` | ✅ OK | CI для Штурвала. |
| `site/app.py` | ⚠️ Нужны правки | Удалить блок `if __name__ == "__main__"` (gunicorn). При рефакторинге — вынести LLMClient. |
| `site/templates/index.html` | ✅ OK | UI. |
| `site/static/favicon.svg` | ✅ OK | Иконка (из loadtest). |
| `drhider.py` | ⚠️ МОНОЛИТ | 560 строк. Разбить на ~20 модулей. |
| `History/` | ✅ OK | Документация. |
---
## План рефакторинга (Фаза 1: удаление лишнего)
### Шаг 1.1 — Удалить `Dockerfile`
### Шаг 1.2 — Удалить `drhider_server.py`
### Шаг 1.3 — Исправить `requirements.txt` (убрать gunicorn, gevent)
### Шаг 1.4 — Исправить `site/app.py` (убрать блок `if __name__ == "__main__"`)
### Шаг 1.5 — Проверить синтаксис → commit → push
---
## План рефакторинга (Фаза 2: модульная структура)
### Новая структура `drhider/` пакета (вместо `drhider.py` 560 строк):
```
drhider/ # Пакет ядра обфускации
├── __init__.py # re-export: obfuscate_files()
├── config.py # Константы: ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN,
│ # RU_SURNAMES, RU_NAMES, RU_PATRONYMICS,
│ # RU_CITIES, RU_STREETS, FAKE_DOMAINS
├── checksum.py # _checksum_inn10, _checksum_inn12, _checksum_ogrn
├── random_utils.py # _random_digits, _random_letters
├── generators/ # Генераторы фиктивных значений
│ ├── __init__.py # ENTITY_GENERATORS маппинг
│ ├── phone.py # generate_phone
│ ├── email.py # generate_email
│ ├── inn.py # generate_inn10, generate_inn12
│ ├── ogrn.py # generate_ogrn
│ ├── kpp.py # generate_kpp
│ ├── bik.py # generate_bik
│ ├── accounts.py # generate_rs, generate_ks
│ ├── passport.py # generate_passport
│ ├── company.py # generate_company
│ ├── person.py # generate_person
│ └── address.py # generate_address
├── extractor.py # _extract_text, _expand_zips, _convert_pdfs_to_docx
├── scanner.py # Проход 1: _scan_regex, _scan_llm_ner
├── replacer.py # Проход 2: _replace_in_docx, _replace_in_text, _apply_replacements
├── builder.py # Сборка: _build_zip, _build_mapping_csv
├── obfuscator.py # TwoPassObfuscator — оркестратор
└── llm_client.py # LLMClient (из app.py → сюда)
site/ # Flask-приложение
├── __init__.py
├── app.py # Только create_app() + регистрация blueprint'ов
├── routes/
│ ├── __init__.py # Регистрация всех blueprint'ов
│ ├── main_bp.py # GET /
│ ├── health_bp.py # GET /health
│ └── api_bp.py # POST /api/drhider
├── templates/index.html
└── static/favicon.svg
```
### Принципы:
- Каждый файл ≤ 50 строк (где возможно)
- Каждый файл — одна ответственность
- Максимум docstring и инлайн-комментариев
- Все импорты явные, никаких `import *`
---
## Результат рефакторинга (2026-07-12)
### Итоговая структура
```
drhider/ # Пакет ядра обфускации
├── __init__.py # re-export: obfuscate_files, LLMClient, TwoPassObfuscator
├── config.py # Константы: ENTITY_PATTERNS, словари имён/городов/улиц
├── checksum.py # Контрольные суммы: ИНН10, ИНН12, ОГРН
├── random_utils.py # Утилиты: random_digits, random_letters
├── llm_client.py # LLMClient — HTTP-клиент к LLM API
├── extractor.py # Извлечение текста + expand_zips + convert_pdfs_to_docx
├── scanner.py # Проход 1: scan_regex, scan_llm_ner
├── replacer.py # Проход 2: apply_replacements, replace_in_docx, replace_in_text
├── builder.py # Сборка: build_zip, build_mapping_csv
├── obfuscator.py # TwoPassObfuscator — оркестратор
└── generators/ # Генераторы фиктивных значений
├── __init__.py # ENTITY_GENERATORS маппинг
├── phone.py, email.py # Телефон, email
├── inn.py, ogrn.py, kpp.py # ИНН, ОГРН, КПП
├── bik.py, accounts.py # БИК, счета (р/с, к/с)
├── passport.py # Паспорт
├── company.py, person.py # Компания, ФИО
└── address.py # Адрес
site/ # Flask-приложение
├── app.py # create_app() + VERSION (20 строк)
├── routes/
│ ├── __init__.py # register_routes()
│ ├── main_bp.py # GET /
│ ├── health_bp.py # GET /health
│ └── api_bp.py # POST /api/drhider
├── templates/index.html
└── static/favicon.svg
```
### Что изменилось
| Было | Стало |
|---|---|
| `drhider.py` — 560 строк монолит | Пакет `drhider/` — 22 модуля по 20–100 строк |
| `site/app.py` — 100 строк (всё в одном файле) | `app.py` 33 строки + 3 blueprint'а по 2050 строк |
| `Dockerfile` | Удалён (Штурвал сам) |
| `drhider_server.py` | Удалён (Flask заменяет) |
### Коммиты
- `a2f754a` — Фаза 1: удалён Dockerfile, drhider_server.py, gunicorn
- `37581eb` — Фаза 2: config.py, checksum.py, random_utils.py, generators/
- `2cf4e08` — Фаза 2: llm_client.py, extractor.py, scanner.py, replacer.py, builder.py, obfuscator.py
- `51bc1a7` — Фаза 2: site/app.py → blueprint'ы, старый drhider.py удалён
| Переменная | Значение |
|---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` |
| `LLM_MODEL` | `gpt-oss-120b` |
+24
View File
@@ -0,0 +1,24 @@
# Документация — 2026-07-12
**После рефакторинга** создана полная документация:
### Созданные файлы
| Файл | Назначение |
|---|---|
| `README.md` | Обзор проекта, правила для агентов, структура, API, env vars |
| `ARCHITECTURE.md` | Архитектура: схема, двухпроходная обфускация, поток данных, описание каждого модуля |
| `DEPLOY.md` | Деплой, ВМ, переменные окружения, локальная разработка, как добавить генератор |
| `.github/copilot-instructions.md` | Правила для Copilot (не менять без «делай», не смотреть локальные папки, коммитить после каждой правки) |
### Ключевые факты зафиксированы
- Платформа: Штурвал (Managed Flask), без Dockerfile/gunicorn
- URL: https://drhider.pythonk8s.dev.nubes.ru/
- Дизайн фронтенда: `/home/naeel/nubes/design/`
- ВМ (legacy): `5.172.178.213`, ssh-ключ `~/.ssh/naeel_vm_id_ed25519`
- Переменные окружения: LLM_API_KEY, LLM_URL, LLM_MODEL
### Коммит
`0707d53` — Документация: README.md, ARCHITECTURE.md, DEPLOY.md, .github/copilot-instructions.md
@@ -0,0 +1,32 @@
# Запрос к Соннету — ERR_TIMED_OUT при открытии нового окна (2026-07-12)
## Симптом
DrHider на Managed Flask (Штурвал/pythonk8s). При открытии страницы в НОВОМ окне браузера — ERR_TIMED_OUT (20+ секунд). Старые вкладки работают нормально.
## Факты
- Изнутри кластера (kubectl exec → curl): HTTP 200 за 2.1 секунды
- Снаружи (браузер, curl с локальной машины): таймаут 10+ секунд
- v1 работало с gunicorn (CMD: `gunicorn --bind :5000 --worker-class gevent`)
- Сейчас: `app.run(host="0.0.0.0", port=5000)` — Flask dev server (Werkzeug)
- Страница: 12 KB HTML, 2 статических файла (logo.svg, favicon.svg — оба 2.8 KB)
- Ноль внешних зависимостей (никаких CDN, шрифтов, JS-библиотек)
- HTTP-заголовки: Cache-Control: no-cache, no-store, must-revalidate
- Штурвал запускает: cd site && python app.py
## Вопросы
1. Может ли Flask dev server (Werkzeug) быть причиной ERR_TIMED_OUT на новых соединениях, в то время как gunicorn работал нормально?
2. Если да — какой механизм? (keep-alive, thread pool, socket backlog?)
3. Если нет — что ещё может вызывать таймаут именно на НОВЫХ окнах при работающих старых?
4. Нужно ли вернуть gunicorn или есть другой способ заставить app.run() работать стабильно?
5. Может ли `Cache-Control: no-store` заставлять браузер делать лишние запросы при открытии нового окна?
## Контекст платформы
Штурвал — Managed Flask на Kubernetes:
- Запускает: `cd /var/www && pip install -r requirements.txt && cd site && python app.py`
- Readiness probe: `tcp-socket :5000`
- Нет Dockerfile, нет nginx перед приложением
- Ingress: внешний LoadBalancer → ClusterIP сервис → под
+83
View File
@@ -0,0 +1,83 @@
# Запрос к Соннету — анализ результатов DrHider v3 (2026-07-12)
## Контекст
DrHider v3 — обфускация документов через LLM + Markdown. Результаты уже есть.
Покажи Соннету реальный mapping.csv и фрагмент обфусцированного .md.
## Промпт для Соннета
Вот текущий LLM-промпт в scanner.py:
```
You are a PII detector. Find ALL sensitive or private information in the text below.
Return a JSON array. Each item must have:
- "type": short snake_case label describing the entity
(e.g. person_name, phone, email, address, inn, company, passport,
contract_number, employee_id, bank_account — WHATEVER you see)
- "value": the EXACT string from the text (copy verbatim)
Rules:
- Copy value VERBATIM — same spaces, punctuation, case as in text
- If same value appears multiple times — include only once
- Invent the type label yourself based on what you see
- Return ONLY the JSON array, no other text, no markdown wrapping
```
Результат mapping.csv (первые 10 строк):
```csv
тип_данных,оригинал,замена
text,"""****XXX****001****""","""****IPD****084****"""
text,"""НУБЕС""","""ФИОЩЦ"""
phone,+7 (495) 789-41-35,+7 (343) 986-75-93
email,info@nubes.ru,bxkuno@company.local
company,"ЗАО ""****XXX****001****""",ООО «Спектр»
company,"ЗАО ""XXX001""",ЗАО «Меридиан»
inn_ul,ИНН 9706005293,ИНН 9325731296
text,Исполнителем Заказчику Сторонами,Морозов М.П.
kpp,КПП 772401001,КПП 051543039
```
Фрагмент обфусцированного .md:
```markdown
**ООО «Спектр»** именуемое в дальнейшем "Заказчик", в лице Генерального директора ФИО,
действующего на основании Устава, и **ООО **"**НУБЕС**"**, именуемое в дальнейшем
"Исполнитель", в лице Генерального директора Степанов Н.М.
```
## Проблемы
1. **Контекстная роль Исполнителя** — сервис должен быть УНИВЕРСАЛЬНЫМ, без хардкода конкретных названий. LLM должна из контекста понимать: «вот эта компания — Исполнитель (сторона договора, оказывающая услуги), её НЕ заменяем». Как научить LLM этому без указания конкретных имён?
2. **Тип "text" для многих сущностей** — LLM не всегда правильно определяет тип (например, "Исполнителем Заказчику Сторонами" — это не ПДн, а просто текст договора)
3. **Regex находит структурированное (ИНН, телефон), LLM — остальное** — есть дублирование
## Вопросы
1. Как научить LLM определять Исполнителя из контекста договора (без хардкода имён) и НЕ заменять его данные?
2. Как заставить LLM точнее определять тип сущности (не "text" для всего подряд)?
3. Нужно ли добавить в промпт примеры того что НЕ является ПДн (юридические термины, роли сторон)?
4. Стоит ли передавать LLM уже найденное regex'ом чтобы избежать дублирования?
5. Правильно ли что промпт на английском, а документы на русском?
## Текущий промпт (полностью)
```
You are a PII (Personally Identifiable Information) detector.
Find ALL sensitive or private information in the text below.
Return a JSON array. Each item must have:
- "type": short snake_case label describing the entity
(e.g. person_name, phone, email, address, inn, company, passport,
contract_number, employee_id, bank_account — WHATEVER you see)
- "value": the EXACT string from the text (copy verbatim)
Rules:
- Copy value VERBATIM — same spaces, punctuation, case as in text
- If same value appears multiple times — include only once
- Invent the type label yourself based on what you see
- Return ONLY the JSON array, no other text, no markdown wrapping
Text:
{combined[:8000]}
```
@@ -0,0 +1,47 @@
# Запрос к Соннету — нумерация вместо генерации фейков (2026-07-12)
## Контекст
DrHider обфусцирует документы: ищет ПДн → заменяет на фиктивные значения.
Сейчас используется генерация "похожих" фейков (Иванов И.И., ООО «Ромашка», +7(495)...).
## Проблема
1. **Адреса → абракадабра.** Ул. Стекольная → иг. 8ц Лжчюсвхшбр. Потому что fallback-генератор меняет кириллицу на случайную кириллицу.
2. **Сложность.** 11 генераторов под каждый тип данных. Каждый надо поддерживать.
3. **Ложное ощущение реальности.** Фейковые ИНН с правильной контрольной суммой выглядят как настоящие.
## Идея
Заменить всю генерацию на **нумерацию по типам:**
| Оригинал | Замена |
|---|---|
| Иванов И.И. | `Лицо_0042` |
| ООО «НУБЕС» | `Компания_0003` |
| ул. Стекольная, д.7 | `Адрес_0015` |
| +7 (495) 789-41-35 | `Телефон_0007` |
| info@nubes.ru | `Email_0004` |
| ИНН 9706005293 | `ИНН_0002` |
| Р/с 40702... | `Счёт_0001` |
| договор-XXX001.docx | `Файл_0006` |
## Вопросы
1. **Плюсы/минусы** нумерации vs генерации фейков для задачи обфускации документов?
2. **Как нумеровать?** Глобальный счётчик на каждый тип? Или в рамках одного документа?
3. **Стоит ли сохранять формат?** Например `Телефон_0007` vs `+7 (000) 000-00-07`?
4. **mapping.csv** — при нумерации он становится главным документом аудита. Это ок?
5. **UUID вместо номеров?** `Лицо_a3f4b2` — лучше или хуже последовательных номеров?
## Текущий mapping.csv (для контекста)
```csv
тип_данных,оригинал,замена
phone,+7 (495) 789-41-35,+7 (495) 906-87-80
email,info@nubes.ru,ypiorej@company.local
company,"ЗАО ""XXX001""",ООО «Альянс»
inn_ul,ИНН 9706005293,ИНН 1846691249
text,Исполнителем Заказчику Сторонами,Кузнецов О.В.
text,"115404, г. Москва, ул. 1-я Стекольная, д.7с2","084810, б. Ввжзгд, гф. 6-в Фтпжыобнзщ, я.8с1"
```
@@ -0,0 +1,54 @@
# Запрос к Соннету — нумерация vs фейки для downstream LLM (2026-07-12)
## Контекст
Я делаю сервис **DrHider** — обфускация документов. Находит ПДн → заменяет.
После обфускации документы идут на дальнейшую LLM-обработку — **сверка спецификаций**
(другой сервис contracts, извлекает типы/номера/даты/контрагентов через LLM).
Сейчас генерация фейков: `Иванов И.И.``Петров А.С.`, `ООО «Ромашка»``ООО «Спектр»`.
**Проблема:** для неизвестных типов (адреса, нестандартные ID) — character-class замена
даёт абракадабру: `ул. Стекольная``иг. 8ц Лжчюсвхшбр`. Downstream LLM не может это обработать.
## Варианты замены
### А. Чистая нумерация
`Иванов И.И.``Лицо_0042`, `ООО «Ромашка»``Компания_0003`, `ул. Ленина``Адрес_0015`
Плюс: никакой абракадабры. Минус: LLM теряет контекст («Лицо_0042 заключил договор» — не понимает семантику).
### Б. Фейки (сейчас)
`Иванов И.И.``Петров А.С.`, `ООО «Ромашка»``ООО «Спектр»`
Плюс: LLM понимает роли. Минус: сложно (11 генераторов), ложное ощущение реальности,
для неизвестных типов — абракадабра.
### В. Гибрид: шаблон + номер
`Иванов И.И.``Иванов_5677`, `ООО «Ромашка»``ООО_Технология_0034`,
`ул. Стекольная, д.7``ул_Ленина_0015`, `+7 (495) 789-41-35``+7_495_000_0042`,
`info@nubes.ru``email_0007@fake`
Плюс: LLM видит что это человек/компания/адрес, номер гарантирует уникальность и обфускацию.
Минус: всё равно нужны шаблоны для каждого типа.
## Вопросы
1. Какой вариант лучше для downstream LLM-обработки (извлечение контрагентов, дат, номеров)?
2. Вариант В — достаточно ли LLM поймёт что `Иванов_5677` это персональные данные?
3. Для неизвестных типов — `[Данные_NNNN]` как fallback, ок?
4. Глобальная нумерация (сквозная через все документы пакета) vs локальная (в рамках одного файла)?
5. Стоит ли сохранять частичную структуру (например телефон `+7_495_000_0042` сохраняет формат номера)?
## Downstream задача (для контекста)
LLM в сервисе contracts получает текст документа и должна извлечь:
- Контрагент («с кем договор»)
- Номер и дату договора
- Список услуг с ценами (таблица)
Промпт для contracts LLM (фрагмент):
```
Извлеки из текста: тип документа, номер, дату, контрагента.
Верни JSON: {"type":"договор","number":"...","date":"...","counterparty":"..."}
```
+76
View File
@@ -0,0 +1,76 @@
# Запрос к Соннету — универсальная архитектура DrHider
## Контекст
Проект DrHider — обфускация документов (замена персональных данных на фиктивные). Сейчас:
- Python/Flask, без БД, Managed Flask на платформе Штурвал
- Двухпроходная архитектура: сбор сущностей → замена
- Проход 1: regex-паттерны (телефон, email, ИНН, ОГРН, КПП, БИК, счета, паспорт) + LLM NER (ФИО, компании, адреса, паспорта)
- Проход 2: замена по словарю mapping
## Проблема
Архитектура **в корне неверна** — жёстко привязана к фиксированному списку типов сущностей:
```python
# config.py — жёстко зашитые паттерны
ENTITY_PATTERNS = {
"phone": r'...',
"email": r'...',
"inn_fl": r'ИНН\s*\d{12}',
...
}
```
```python
# scanner.py — жёстко зашитый промпт
prompt = (
"Найди ВСЕ следующие сущности:\n"
"1. ФИО\n2. Компании\n3. Адреса\n4. Паспортные данные\n"
)
```
Если новый документ содержит СНИЛС, водительское удостоверение, номер договора, ИНН без префикса «ИНН» — система их НЕ обнаружит. Надо править config, generators, scanner, маппинги.
## Что нужно
**Универсальная архитектура**, где система САМА определяет что скрывать в любом документе:
1. Не привязана к списку типов
2. Не требует добавления паттернов под каждый новый вид данных
3. LLM сама решает что является персональными/конфиденциальными данными
4. Генерация фиктивных значений — тоже универсальная (не 11 отдельных функций)
## Текущая структура (полная)
```
drhider/
├── config.py # ENTITY_PATTERNS (10 regex), словари имён/городов
├── checksum.py # Контрольные суммы ИНН/ОГРН
├── random_utils.py # random_digits, random_letters
├── generators/ # 11 файлов: phone, email, inn, ogrn, kpp, bik, accounts, passport, company, person, address
├── llm_client.py # HTTP-клиент к LLM API
├── extractor.py # Извлечение текста + expand_zips + convert_pdfs_to_docx
├── scanner.py # scan_regex (regex) + scan_llm_ner (LLM NER с жёстким промптом)
├── replacer.py # apply_replacements, replace_in_docx, replace_in_text
├── builder.py # build_zip, build_mapping_csv
├── obfuscator.py # TwoPassObfuscator — оркестратор
├── site/app.py # Flask (3 blueprint'а)
└── site/templates/ # HTML-интерфейс
```
## Вопросы к Соннету
1. Как перестроить архитектуру чтобы обнаружение было универсальным (LLM сама решает что скрывать)?
2. Нужен ли regex вообще или достаточно одного LLM с правильным промптом?
3. Как сделать генерацию фиктивных значений универсальной? (Не 11 функций под каждый тип, а что-то общее)
4. Двухпроходная схема (сбор→замена) — сохранять или перейти на однопроходную (LLM сразу возвращает обфусцированный текст)?
5. Как должен выглядеть промпт чтобы LLM возвращала структурированный результат (что найдено + на что заменить)?
6. Стоит ли сохранять DOCX-форматирование (сейчас runs склеиваются-разделяются) или проще отдать LLM plain text?
## Ограничения
- Документы до 200 MB
- Форматы: .docx, .pdf, .txt, .zip
- LLM: OpenAI-совместимое API (aillm.ru, модель gpt-oss-120b, 8000 токенов)
- Без БД, всё в памяти
- Платформа: Managed Flask на Kubernetes
@@ -0,0 +1,19 @@
# Ответ Соннета — ERR_TIMED_OUT (2026-07-12)
## Причина
Werkzeug dev server: **один поток на TCP-соединение**. Браузер держит 6 keep-alive соединений на вкладку. 6 потоков заняты idle-соединениями → backlog (5) переполнен → новое окно получает SYN-дроп → таймаут.
gunicorn+gevent: async I/O, idle-соединения не занимают ресурсы.
## Решение
Три варианта:
| Вариант | Что | Изменения |
|---|---|---|
| A (waitress) | Pure Python WSGI | `requirements.txt` + 2 строки в `app.py` |
| B (gevent) | Async, как gunicorn | `requirements.txt` + 2 строки в `app.py` |
| C (gunicorn) | Точно как v1 | `requirements.txt` + смена CMD в Штурвале |
Соннет рекомендует: **A** если Штурвал не меняет CMD, **C** если меняет.
+47
View File
@@ -0,0 +1,47 @@
# Ответ Соннета v2 — улучшение LLM-промпта (2026-07-12)
## Q1. Как научить LLM определять Исполнителя из контекста?
**Двухфазная инструкция в промпте:**
- STEP 1: определи кто «Исполнитель» — его данные НЕ трогать
- STEP 2: найди всё остальное ПДн
Убирает хардкод `НУБЕС` из кода. Работает на любом договоре.
## Q2. Точная классификация типов
Вместо `"Invent the type label yourself"`**фиксированный список:**
`person_name, company, phone, email, address, inn, kpp, ogrn, bik, passport, contract_number, bank_account, other_id`
Если сомневаешься — НЕ включай.
## Q3. Примеры что НЕ является ПДн
Обязательно добавить блок:
```
NOT PII:
- Юридические роли: Исполнитель, Заказчик, Стороны
- Термины договора: Услуги, Приложение, Договор, НДС
- Должности: Генеральный директор
- Сама компания-Исполнитель
```
## Q4. Передавать LLM уже найденное regex'ом
`scan_llm_ner` получает `mapping.keys()` → промпт: "Already captured (skip these): ..."
Исключает дублирование, экономит токены.
## Q5. Язык промпта
Гибрид: структурные инструкции на английском, NOT PII и роли — на русском.
---
## План реализации (scanner.py)
1. Убрать хардкод `НУБЕС` из `scan_regex`
2. `scan_llm_ner` — передавать `mapping.keys()` как "Already captured"
3. Новый промпт:
- Фиксированный список типов
- Блок NOT PII (русский)
- Двухфазная инструкция (STEP 1: Исполнитель, STEP 2: остальное)
@@ -0,0 +1,46 @@
# Ответ Соннета v3 — нумерация вместо генерации фейков (2026-07-12)
## Q1. Нумерация vs фейки
- ✅ Адреса без абракадабры, код проще, аудит прозрачнее
- ❌ Документ нечитаем (`Лицо_0042 заключил договор с Компания_0003`)
- Вывод: для аудита/хранения — отлично, для показа людям — фейки лучше
## Q2. Глобальный счётчик на весь пакет
Одна сущность в разных документах → один номер. `TwoPassObfuscator` хранит counters.
## Q3. Сохранять формат?
Рекомендация: `Тип_NNNN` — проще всего, аудит максимально прозрачен.
## Q4. mapping.csv — главный документ аудита
Да, это улучшение. mapping.csv — секретный ключ деобфускации.
## Q5. Номера vs UUID
Номера (`Лицо_0042`) — лучше для читаемости и аудита.
---
## План изменений
### Удалить
- `drhider/generators/` — все 12 файлов
- `drhider/checksum.py` — больше не нужен (ИНН без контрольных сумм)
- `drhider/random_utils.py` — больше не нужен
### Изменить
- `scanner.py`: добавить `TYPE_PREFIXES` + `_next_token()`, убрать импорты генераторов
- `obfuscator.py`: `TwoPassObfuscator` хранит `counters` dict
- `config.py`: убрать словари имён/городов (RU_SURNAMES, ...)
### Новая логика замены
```python
TYPE_PREFIXES = {
"person_name": "Лицо", "company": "Компания", "phone": "Телефон",
"email": "Email", "address": "Адрес", "inn": "ИНН", ...
}
_FALLBACK = "Данные"
def _next_token(entity_type, counters):
prefix = TYPE_PREFIXES.get(entity_type, _FALLBACK)
counters[prefix] = counters.get(prefix, 0) + 1
return f"{prefix}_{counters[prefix]:04d}"
```
@@ -0,0 +1,19 @@
# Ответ Соннета v4 — гибридный формат замен (2026-07-12)
## Итог: Вариант В (шаблон + номер)
| Тип | Формат замены | Пример |
|---|---|---|
| ФИО | `Фамилия_NNNN` | `Иванов_5677` |
| Компания | `ООО_Слово_NNNN` | `ООО_Технология_0034` |
| Адрес | `ул_Шаблон_NNNN` | `ул_Ленина_0015` |
| Телефон | `+7_код_000_NNNN` | `+7_495_000_0042` |
| Email | `email_NNNN@fake` | `email_0007@fake` |
| ИНН | `ИНН_NNNNNNNNNN` | `ИНН_0000000042` |
| Неизвестный тип | `[Тип_NNNN]` | `[Адрес_0023]` |
## Ключевые принципы
1. **Читаемо человеком** — не ужиматься, пусть будет естественно
2. **Однозначно для LLM** — префикс указывает тип сущности
3. **Глобальная нумерация** — одна сущность = один номер во всех файлах пакета
4. **Цены/суммы** — не обфусцировать (нужны для downstream сверки)
+78
View File
@@ -0,0 +1,78 @@
# Ответ Соннета — универсальная архитектура DrHider (2026-07-12)
Кратко: 6 вопросов → 6 ответов → карта изменений.
---
## Q1. Как сделать обнаружение универсальным?
Промпт должен звучать не «найди вот эти типы», а «найди ВСЁ, что выглядит как приватная информация, и сам назови тип».
Затрагивает: `scanner.py` (промпт), `builder.py` (тип для mapping.csv).
---
## Q2. Regex или LLM?
**Гибрид — правильный выбор:**
- **Regex = pre-pass** для структурированных данных (телефон, email, ИНН, БИК). Экономит токены LLM.
- **LLM = post-pass** для неструктурированного (имена, адреса, нестандартные ID).
- Дублирования не страшны — mapping dict сам отсеет.
---
## Q3. Генерация фиктивных значений — универсальная?
Два уровня:
1. **Известные типы** — специализированные генераторы (оставить как есть).
2. **Неизвестные типы** — fallback-генератор: character-class preserving замена (цифры→цифры, буквы→буквы той же длины).
Дополнительно: в промпт добавить `"category": "person|org|contact|id|financial|other"` — 6 категорий вместо 10+ типов.
---
## Q4. Двухпроходная или однопроходная?
**Двухпроходная — обязательно.** Причина: «Иванов» в 5 документах должен заменяться одинаково. Однопроход не может этого гарантировать.
Текущий `TwoPassObfuscator` — правильный, не трогать.
---
## Q5. Новый промпт
```
You are a PII detector. Find ALL sensitive or private information.
Return JSON array: [{"type": "short_label", "value": "exact_string"}]
Rules:
- Copy "value" VERBATIM from text
- "type" is snake_case label you invent
- Same value → include once
- Return ONLY JSON
```
Ключевое: нет ограничения на типы, value verbatim.
---
## Q6. DOCX или Markdown?
- **Внутренняя обработка:** Markdown проще парсить, LLM понимает лучше.
- **На выходе:** опция `output_format: "docx" | "md"`. По умолчанию `"docx"`.
---
## Итоговая карта изменений
```
scanner.py — новый промпт (убрать список типов, LLM сама называет типы)
generators/ — добавить fallback-генератор для неизвестных типов
obfuscator.py — вызывать fallback если тип неизвестен
api_bp.py — принять параметр output_format
replacer.py — добавить replace_to_markdown()
builder.py — упаковывать .md если output_format=md
```
**Не трогать:** двухпроходная схема, checksum-генераторы, ZIP-безопасность.
+45
View File
@@ -0,0 +1,45 @@
# v3.0.0 — универсальная архитектура (2026-07-12)
Переход на Markdown-пайплайн + универсальное LLM-обнаружение.
## Что изменилось
### 1. `generators/fallback.py` — новый
Character-class preserving замена для неизвестных типов сущностей.
Цифры→цифры, буквы→буквы, спецсимволы→без изменений.
### 2. `scanner.py` — универсальный LLM-промпт
- Убран жёсткий список типов (person, company, address, passport)
- LLM САМА придумывает тип (snake_case) — person_name, contract_number, employee_id...
- Если тип не совпадает с известными — fallback-генератор
- Промпт на английском (LLM точнее)
### 3. `extractor.py` — Markdown-пайплайн
- `docx_to_markdown()`: DOCX → MD (Heading → #, Bold → **, таблицы → |...|)
- `pdf_to_markdown()`: PDF → MD (текст + таблицы, без форматирования)
- `extract_text()` возвращает только str (без docx-объектов)
- Удалён `convert_pdfs_to_docx()` (PDF → MD напрямую)
### 4. `replacer.py` — упрощён
- Удалён `replace_in_text()` (заменён на `apply_replacements()` + `.encode()`)
- Оставлен `replace_in_docx()` для будущего DOCX-выхода
### 5. `obfuscator.py` — новый пайплайн
- Pass 1: extract MD → scan_regex + scan_llm_ner
- Pass 2: apply_replacements к MD → .md файлы
- Нет docx-объектов, нет convert_pdfs_to_docx
## Коммиты
- `579f9c8` — generators: fallback.py
- `2ceeb05` — scanner.py: универсальный промпт
- `af2f2a9` — extractor.py: MD + obfuscator.py: новый пайплайн
- `f5427b0` — replacer.py: удалён replace_in_text
- `2a2cc14` — v3.0.0
## Плюсы
- LLM точнее (нет XML-шума, только Markdown)
- Новые типы сущностей — без правки кода
- Код проще: -120 строк, -1 функция, -1 модуль (convert_pdfs_to_docx)
- Единый внутренний формат (Markdown)
+51
View File
@@ -0,0 +1,51 @@
# План v3 — универсальная архитектура (2026-07-12)
## Цель
Убрать жёсткую привязку к фиксированному списку типов сущностей.
Перейти на Markdown как внутренний формат.
## Изменения по модулям
### 1. `scanner.py` — универсальный LLM-промпт
- Убрать список типов из промпта
- LLM сама называет типы (snake_case)
- `value` — verbatim из текста
### 2. `generators/` — fallback-генератор
- Новый `fallback.py`: character-class preserving замена
- `__init__.py`: добавить в ENTITY_GENERATORS
- `obfuscator.py`: использовать fallback для неизвестных типов
### 3. `extractor.py` — единый MD-пайплайн
- DOCX → MD (python-docx: стили, форматирование)
- PDF → MD (pdfplumber: текст + таблицы)
- TXT → как есть
- Убрать convert_pdfs_to_docx (не нужен)
- Убрать хранение docx-объектов
### 4. `replacer.py` — упростить
- `apply_replacements(text)` — уже есть
- `replace_in_docx(doc, mapping)` — сохранить как утилиту для DOCX-выхода
- Удалить `replace_in_text()` (заменяется на apply_replacements)
### 5. `obfuscator.py` — обновить пайплайн
- Pass 1: extract MD → scan_regex + scan_llm_ner
- Pass 2: apply_replacements к MD
- Опционально: replace_in_docx к оригинальному DOCX
### 6. `api_bp.py` — output_format
- Параметр `output_format: "md" | "docx"` (по умолчанию md)
### 7. `.doc` — потом
- Добавим convert_doc_to_docx через LibreOffice отдельно
## Порядок реализации
1. `generators/fallback.py` + обновить `__init__.py`
2. `scanner.py` — новый промпт
3. `extractor.py` — MD-конвертация
4. `replacer.py` — упростить
5. `obfuscator.py` — новый пайплайн
6. `api_bp.py` — output_format
7. Проверить всё → commit
+30
View File
@@ -0,0 +1,30 @@
# План v3.1 — пофайловая обработка + прогресс в таблице (2026-07-12)
## Проблема
Фронтенд v3.0 отправляет все файлы одним запросом → долгий LLM-вызов → таймаут.
Нужно: обрабатывать по одному, прогресс в таблице, все результаты → один ZIP.
## Решение
### Фронтенд (index.html)
1. **XHR** вместо fetch — надёжнее для blob, явный timeout
2. **Колонка «Статус»** в таблице файлов
3. **По одному файлу:** цикл, каждый файл → отдельный XHR → ZIP
4. **Прогресс в таблице:** ⏳ → ✓ 2.1 сек (или ✗ ошибка)
5. **JSZip** — склеивает все полученные ZIP'ы в один
6. **Скачивание** — один файл `drhider_output.zip` в конце
### Статусы в таблице
| Статус | Отображение |
|---|---|
| Ожидание | ⏳ |
| Успех | ✓ 2.1 сек |
| Ошибка | ✗ сообщение |
### Бэкенд
Без изменений. API уже принимает один файл.
### НЕ трогать
- gunicorn не нужен (файлы по одному, каждый запрос ≤10 сек)
- app.run() — остаётся
- drhider пакет — без изменений
@@ -0,0 +1,44 @@
# Запрос к Opus: валидация MSS Clamping плана
**Дата:** 2026-07-13
---
## Контекст
Кластер: bare-metal Kubernetes, 4 ноды, Cilium CNI (Geneve-туннель), kube-vip для VIP.
Штурвал управляет ingress через Helm-чарт (`shturval-ingress-controller-2.12.1`).
## Проблема
При `externalTrafficPolicy: Cluster` большие файлы (>5 MB) вызывают TCP stall на 51 секунду.
Диагностика: cross-node Geneve-туннель добавляет overhead (~50 байт) → пакет > MTU 1500 → ICMP Frag Needed блокируется → TCP RTO backoff.
Текущий костыль: `externalTrafficPolicy: Local` + `kubectl scale replicas=4` (Штурвал сбрасывает на 2 каждые ~час).
## Предлагаемое решение
Вместо костыля — выставить MTU в Cilium ConfigMap:
```yaml
# kubectl edit configmap -n kube-system cilium-config
mtu: 1450
```
После этого:
1. Вернуть `externalTrafficPolicy: Cluster`
2. Оставить ingress `replicaCount: 2` (стоковое значение Штурвала)
3. Никаких ручных scale
## Вопросы к Opus
**Читать только:** `docs/MSS-CLAMPING-PLAN.md`, `docs/KUBEVIP-EXTERNAL-TRAFFIC-POLICY.md`, `docs/INGRESS-SOLUTIONS.md`.
1. Корректно ли решение? Есть ли подводные камни при смене MTU через Cilium ConfigMap на работающем кластере?
2. Нужно ли что-то ещё кроме `mtu: 1450`? (например, перезапуск подов, пересоздание туннелей)
3. Как проверить что MSS clamping реально работает после изменения? (конкретные команды)
4. Есть ли риск для других сервисов (Keycloak, etc.) при смене MTU?
5. Если Cilium ConfigMap недоступен — iptables-вариант (`TCPMSS --clamp-mss-to-pmtu`) равноценен? На какую ноду вешать правило?
6. Итоговый вердикт: это правильный путь, или есть более грамотный вариант?
**Не лезть:** в код приложения, в TMP, в History (кроме указанных трёх файлов).
@@ -0,0 +1,35 @@
# Opus: проверка PROBLEM-AND-SOLUTION.md на ошибки
**Дата:** 2026-07-13
---
## Найденные ошибки
### 1. TCP backoff — неправильные числа
**Было:** `1с → 3с → 7с → 15с → 25с = 51с` — выдумано.
**Правильно:** начальный RTO = 200ms, удвоение:
```
0.2с → 0.4с → 0.8с → 1.6с → 3.2с → 6.4с → 12.8с → 25.6с = 51с
```
8 ретрансмитов от 200ms = ровно 51 секунда (Linux kernel default).
### 2. DSR — неверное объяснение причины stall
**Было:** «двойной инкапсуляции return-трафика (главная причина stall)».
**Правильно:** stall на **входящем** трафике (клиент → pod), а не на return. DSR оптимизирует обратный путь, но не решает проблему inbound MTU black-hole.
### 3. Ping измеряет pod MTU, не underlay
Утверждение что `ping -M do` доказывает underlay = 1450 — неточно. Ping из пода измеряет MTU пода (1450 = 1500 50 Cilium). Физический underlay = 1450 подтверждён эмпирически (фикс сработал), а не прямым замером.
## Что верно
- Geneve overhead 50 байт: outer IP(20) + UDP(8) + Geneve(8) + inner Eth(14) = 50 ✓
- `mtu: 1450` → pod MTU = 1400 ✓
- `rollout restart ds/cilium` обязателен ✓
- iptables может не работать с eBPF dataplane ✓
- Корень проблемы и решение верны ✓
@@ -0,0 +1,31 @@
# Opus: финальная проверка PROBLEM-AND-SOLUTION.md
**Дата:** 2026-07-13
---
## Вердикт
Документ технически добротный. Диагноз и корневое решение (`mtu: 1450` + `Cluster` + `replicaCount: 2`) — правильные.
## Исправлено
1. **Ethernet 14** — уточнено: «инкапсулируемый L2-кадр 14» (внутренний Ethernet, не внешний)
2. **51с = прикладной таймаут** — смягчено: «браузер/curl разрывает ожидание» вместо «соединение разрывается»
## Что подтверждено как верное
- PMTU blackhole как причина — классический сценарий
- Cilium: `mtu` в configmap = underlay девайса, pod MTU = mtu overhead
- Geneve overhead = 50 байт (внешний IP 20 + UDP 8 + Geneve 8 + L2 14)
- Решение: `mtu: 1450` → pod 1400 → Geneve-пакет 1450 → влазит в path 1450
- `rollout restart ds/cilium` обязателен
- `externalTrafficPolicy: Local``Cluster` после фикса MTU
- DSR решает только обратный путь, stall на inbound
- iptables может не работать с eBPF dataplane
## Мелкие замечания (не исправлены, допустимы)
- 200ms RTO — корректно для сценария (уже установленное TCP-соединение)
- Тип туннеля (Geneve vs VXLAN) — overhead одинаков (50), математика не меняется
- Замеры из таблицы результатов — эмпирические, из кода не подтвердить
@@ -0,0 +1,77 @@
# Opus: валидация MSS Clamping плана
**Дата:** 2026-07-13
---
## Ключевое исправление
В плане ошибка: `mtu: 1450` в cilium-config — это MTU **underlay-сети**, не туннеля. Cilium сам вычитает 50 байт на Geneve:
```
mtu: 1450 → tunnel MTU = 1450 50 = 1400
```
По умолчанию Cilium автодетектит underlay 1500 → туннель 1450. Если stall всё равно есть — underlay реально < 1500. **Число надо измерить, не гадать.**
---
## Ответы на вопросы
### 1. Корректно ли решение?
Да, направление верное. Но:
- ConfigMap сам не применяется — нужен `rollout restart ds/cilium`
- Число 1450 — гипотеза, нужен замер между нодами
- `mtu` глобален на весь кластер
- Существующие TCP-соединения не поменяют MSS — только новые
### 2. Что ещё нужно?
1. Измерить underlay MTU: `ping -M do -s 1472 <ip_другой_ноды>` (уменьшать пока не пройдёт)
2. `kubectl rollout restart ds/cilium -n kube-system`
3. Пересоздать поды приложения (ingress, drhider)
4. Вернуть `externalTrafficPolicy: Cluster`
### 3. Как проверить?
```bash
# Измерить до:
ping -M do -s 1472 <ip_другой_ноды>
tracepath <ip_другой_ноды>
# После изменения:
kubectl -n kube-system exec ds/cilium -- cilium-dbg status | grep -i mtu
ip link show | grep -E 'cilium|geneve'
tcpdump -ni any 'tcp[tcpflags] & tcp-syn != 0' -vv | grep mss
# Функционально: файл >5 MB без паузы
```
### 4. Риск для других сервисов?
Понижение MTU безопасно (меньше = консервативнее). Небольшой рост числа пакетов, микро-потеря throughput. Функционально ничего не ломает. Рестарт агентов — возможны короткие блипы, лучше в окно.
### 5. iptables равноценен?
**Нет.** Cilium с eBPF обходит netfilter FORWARD — правило может не видеть пакеты. Надёжнее `cilium-config`. Если iptables — то на всех 4 нодах (kube-vip раздаёт VIP на все).
### 6. Вердикт
Путь правильный. Уточнённый план:
1. Измерить path MTU между нодами
2. Выставить `mtu` = измеренное значение (вероятно < 1500)
3. Рестарт агентов + подов
4. Вернуть `Cluster` policy
При `Cluster` проблема реплик исчезает сама (2 хватает).
---
## Моё мнение
Opus прав. План нужно дополнить **замером**. Без замера `1450` — пальцем в небо. Обновлю `MSS-CLAMPING-PLAN.md` с учётом:
- Добавить шаг 0: измерение path MTU
- Исправить формулу: `mtu` = underlay, туннель = `mtu - 50`
- Добавить `rollout restart ds/cilium` + рестарт подов
- Убрать iptables как основной вариант (ненадёжен с Cilium)
@@ -0,0 +1,39 @@
# Запрос к Соннету — архитектура пофайловой обработки с одним ZIP (2026-07-13)
## Задача
DrHider (Flask + waitress). Ограничения:
- Фронтенд должен слать файлы **по одному** (сеть не пропускает большие multipart)
- Бэкенд должен обрабатывать **по одному** (без параллельных LLM-запросов)
- Результат: **один ZIP** со всеми обфусцированными .md + **один общий** mapping.csv
- **Никакой обработки данных в JS** — фронтенд только кнопки и скачивание
## Варианты
### А. Upload → Process
1. `POST /api/upload` — один файл → `{session:"abc"}`
2. Фронтенд циклом шлёт все, получает session
3. `POST /api/process/abc` — бэкенд обрабатывает всё → ZIP
4. Фронтенд скачивает ZIP
### Б. Один запрос, все файлы
1. Фронтенд: один POST, все файлы в FormData
2. Бэкенд: получил все → обработал по одному → ZIP
3. Waitress держит долгое соединение, таймаут 600с
### В. SSE с прогрессом
1. `POST /api/start``{session:"abc"}`
2. `POST /api/upload/abc` — по одному файлу
3. `GET /api/process/abc` — SSE стримит прогресс каждого файла и в конце ZIP
## Вопросы
1. Какой вариант правильный для Flask + waitress?
2. Вариант Б — пройдёт ли большой multipart через ingress (6 файлов × 30-60 KB)?
3. Если А — как чистить сессии? (пользователь закрыл вкладку, файлы остались в памяти)
4. Нужен ли SSE или достаточно простого XHR с таймером?
5. mapping.csv — генерировать на лету (по мере обработки) или после всех файлов?
## Сейчас работает (но сломано)
Сейчас: фронтенд шлёт по одному файлу, каждый получает отдельный ZIP. Пользователь хочет один ZIP в конце. JS обрабатывал ZIP'ы (распаковывал/перепаковывал) — это убрали, всё должно быть на бэкенде.
@@ -0,0 +1,114 @@
# Запрос к Sonnet: анализ обфускации
**Дата:** 2026-07-13
---
**Задача:** Найти причину расхождения между ожидаемыми кодами замен и реальными заменами.
## Ожидаемое поведение
`_next_token()` в `drhider/scanner.py` генерирует коды замен формата:
- `Технология_0001`, `Прогресс_0002` (для компаний)
- `+7_000_000_0001` (для телефонов)
- `email_0001` (для email)
- `ИНН_0001` (для ИНН)
- `Иванов_0001` (для ФИО)
## Реальный результат — `TMP/mapping.csv`
```
тип_данных,оригинал,замена
phone,+7 (495) 789-41-35,+7 (495) 906-87-80 ← НЕ код замены, реальный номер
email,info@nubes.ru,ypiorej@company.local ← НЕ код замены, реальный email
company,ЗАО ****XXX****001****,АО «Прогресс» ← НЕ код замены, реальное название
company,ЗАО XXX001,ООО «Альянс» ← НЕ код замены, реальное название
inn_ul,ИНН 9706005293,ИНН 1846691249 ← НЕ код замены, реальный ИНН
kpp,КПП 772401001,КПП 350443068 ← НЕ код замены, реальный КПП
ks,Кор/сч 30101810400000000225,Кор/сч 30101201490272090211 ← НЕ код замены
ogrn,ОГРН 1207700098759,ОГРН 1048672318430 ← НЕ код замены
company,ООО "НУБЕС",ООО «Спектр» ← НЕ код замены
text,Исполнителем Заказчику Сторонами,Кузнецов О.В.
text,"Обязанности Заказчика\nСвоевременно",Степанов А.В.
text,"Обязанности Исполнителя\nОказывать",Петров П.Е.
text,"Ответственность Заказчика\nЗаказчик",Новиков А.М.
text,"Ответственность Исполнителя\nИсполнитель",Попов О.М.
company,ПАО Сбербанк,АО «Формат»
```
**Ни одного кода замены формата `XXX_0000` в колонке «замена».**
## Реальный результат — `TMP/договор-XXX001-03700_obfuscated.md` (первые строки)
```markdown
**АО «Прогресс»** ... и **ООО **"**НУБЕС**" ... Новиков И.В. ...
```
- `Новиков И.В.` — НЕ заменён (должен быть `Иванов_0001`)
- `ООО "НУБЕС"` — НЕ заменён в тексте (есть в mapping но замена не применена?)
- `г. Москва, 1-я Стекольная 7с5` — адрес не заменён
## Файлы для анализа
**Обязательно прочитать:**
1. `drhider/scanner.py``_next_token()`, `scan_regex()`, `scan_llm_ner()`, TYPE_POOLS, промпт LLM (строки 109-151)
2. `drhider/obfuscator.py` — порядок вызова regex → LLM, как объединяется mapping (строки 63-95)
3. `drhider/config.py` — ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN
4. `drhider/llm_client.py``LLMClient.complete()`
**Прочитать для контекста:**
5. `TMP/mapping.csv` — полный результат
6. `TMP/договор-XXX001-03700_obfuscated.md` — обфусцированный файл
7. `TMP/примеры_договоров_для_ИИ/договор-XXX001-03700.docx` — оригинал (для сверки)
**Не лезть:** `builder.py`, `extractor.py`, `replacer.py`, `session.py`, `templates/`, docs/, History/.
## Ключевые точки для проверки
### 1. `scanner.py` — `_next_token()` (стр. 63-82)
```python
def _next_token(entity_type, counters):
pool, prefix_key = TYPE_POOLS.get(entity_type, _FALLBACK_POOL)
template = random.choice(pool)
key = f"{prefix_key}_{template}"
counters[key] = counters.get(key, 0) + 1
return f"{template}_{counters[key]:04d}"
```
Возвращает `Технология_0001`, `+7_000_000_0001`. **Никак не может вернуть** `АО «Прогресс»` или `+7 (495) 906-87-80`.
### 2. `scanner.py` — `scan_regex()` (стр. 89-108)
```python
mapping[original] = _next_token(entity_type, counters)
```
Всегда вызывает `_next_token`. Результат должен быть кодом замены.
### 3. `scanner.py` — `scan_llm_ner()` (стр. 109-179)
```python
mapping[val] = _next_token(ent_type, counters)
```
Тоже вызывает `_next_token`. Результат должен быть кодом замены.
### 4. `obfuscator.py` — порядок вызова (стр. 86-92)
```python
scanner.scan_regex(text, self._mapping, self._counters) # сначала
scanner.scan_llm_ner(all_texts, self._mapping, ...) # потом
```
### 5. `config.py` — regex-паттерны:
- `phone`: `(?:\+7|8)[\s\-]?\(?\d{3}\)?...` — должно матчить `+7 (495) 789-41-35`
- `email`: `[a-zA-Z0-9._%+-]+@...` — должно матчить `info@nubes.ru`
- `inn_ul`: `ИНН\s*\d{10}` — должно матчить `ИНН 9706005293`
- `COMPANY_PATTERN`: `(?:ООО|ЗАО|...)\s+(?:«[^»]+»|"[^"]+"|...)` — должно матчить `ООО "НУБЕС"` и `ЗАО "XXX001"`
- `PERSON_PATTERN`: `[А-Я][а-я]+\s+[А-Я]\.[А-Я]\.` — должно матчить `Новиков И.В.`
## Главный вопрос
Каждый путь (regex и LLM) вызывает `_next_token()`. `_next_token()` физически не может выдать `АО «Прогресс»` или `+7 (495) 906-87-80`. Откуда в `mapping.csv` берутся реалистичные замены? Кто и где их генерирует В ОБХОД `_next_token()`?
@@ -0,0 +1,17 @@
# Ответ Соннета — архитектура upload+process (2026-07-13)
## Рекомендация: Вариант А + блокирующий /process
```
POST /api/upload → {session_id} (фронт шлёт по одному, N раз)
POST /api/process/{sid} → {status:"done"} (блокирует до конца обработки)
GET /api/download/{sid} → ZIP
```
Без polling — `/process` просто блокирует XHR до готовности. Waitress держит.
## Детали
- Mapping.csv — на лету, аккумулируется после каждого файла
- Сессии — dict в памяти, TTL 30 минут через threading.Timer
- Фронт: цикл upload → process → download. Три простых XHR.
- Никакого JS-кода обработки данных.
@@ -0,0 +1,70 @@
# Sonnet: анализ обфускации — TMP-файлы vs текущий код
**Дата:** 2026-07-13
---
## Главный вывод
**TMP-файлы созданы СТАРОЙ версией кода.** Они не были сгенерированы текущим `_next_token()`-кодом. Это не баг в текущем коде — это артефакт предыдущей реализации.
---
## Доказательства
### 1. Имена файлов — старый формат
Текущий `obfuscator.py` генерирует имена вида `договор-НУБЕС001-03700.md` (меняет расширение). TMP-файлы: `договор-XXX001-03700_obfuscated.md` — суффикс `_obfuscated` и `XXX`-маскировка в имени. Текущий код так не делает.
### 2. Тип `text` — fallback из builder.py, не из сканера
`build_mapping_csv()` определяет тип постфактум по паттернам. Если ни один не совпал → `"text"`. Типа `text` нет в `TYPE_POOLS` scanner.py. Значит строки вида:
```
text,Исполнителем Заказчику Сторонами,Кузнецов О.В.
```
попали из старого кода, который не фильтровал роли (Исполнитель, Заказчик) — текущий LLM-промпт их явно исключает.
### 3. Одна сущность → разные замены
```
email,info@nubes.ru,ypiorej@company.local ← файл 1
email,info@nubes.ru,qifdckke@org.example.ru ← файл 2
```
Текущий `TwoPassObfuscator` гарантирует глобальную согласованность. Старый код обрабатывал файлы по одному — отсюда расхождения.
### 4. Реалистичные замены невозможны из `_next_token()`
```python
TYPE_POOLS = {
"phone": (["+7_000_000"], "phone"), # → "+7_000_000_0001"
"email": (["email"], "email"), # → "email_0001"
}
```
`+7 (495) 906-87-80` или `ypiorej@company.local` физически не могут выйти из этих пулов.
---
## Ответ на главный вопрос
> Кто и где генерирует замены в обход `_next_token()`?
**Никто в текущем коде.** Это артефакты старого кода (версии до v3). Текущий `_next_token()` написан позже.
---
## Что реально нужно проверить
| Вопрос | Как проверить |
|--------|---------------|
| Работает ли текущий код? | Прогнать оригиналы через v0.0.12, посмотреть mapping.csv |
| Regex ловит все сущности? | Проверить `config.py` паттерны на примерах из TMP |
| Адрес `115404, г. Москва...` — почему не заменён? | `ENTITY_PATTERNS` не содержит тип `address` |
| `ООО "НУБЕС"` в тексте не заменён? | Возможно кавычки — regex матчит `«»` но не `""` |
---
## Итог
**Ложное противоречие** — сравнивались TMP-файлы (старый код) с текущим scanner.py. Нужно прогнать текущий код на тех же документах и посмотреть результат.
@@ -0,0 +1,37 @@
# Sonnet: анализ багов обфускации — fix
**Дата:** 2026-07-13
---
## Баг 1: `ООО "НУБЕС"` не заменено
**Причина:** DOCX → Markdown: `**ООО **"**НУБЕС**"`. Кавычка в отдельном run → `**` разрывает строку. Replacer ищет точное совпадение `ООО "НУБЕС"` — не находит.
**Фикс (replacer.py):** добавить `_md_tolerant_pattern()` — паттерн, допускающий `**` между частями строки. При промахе точного совпадения — фоллбэк с этим паттерном.
```python
def _md_tolerant_pattern(original: str) -> str:
MD = r'(?:\*{1,2})?'
parts = re.findall(r'[А-ЯЁа-яёA-Za-z0-9]+|[^А-ЯЁа-яёA-Za-z0-9]', original)
return MD + MD.join(re.escape(p) for p in parts) + MD
```
## Баг 2: `Новиков И.В.` → `ФИО`
**Причина:** В оригинале буквально `ФИО` — незаполненный шаблон. Не баг паттерна.
**Превентивный фикс (config.py):** `\s+``[\s\xa0]+` в PERSON_PATTERN — на случай неразрывных пробелов в DOCX.
```python
PERSON_PATTERN = re.compile(
r'\b[А-Я][а-я]+[\s\xa0]+[А-Я]\.[А-Я]\.'
r'|\b[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+',
)
```
## Моё мнение
**Баг 1 — делать.** Логично, минимально, решает проблему Markdown-жирности.
**Баг 2 — `\xa0` делать** (превентивно). Сам `ФИО` — не баг, документ содержит незаполненный шаблон. Можно добавить `ФИО` в список игнорируемых (исключить из mapping), но не обязательно.
+237
View File
@@ -0,0 +1,237 @@
# Диагностика ERR_CONNECTION_RESET в браузере
**Дата:** 2026-07-14
---
## Хронология
### Фаза 1: MTU (решено)
Проблема: 63KB POST из браузера → `ERR_CONNECTION_RESET`. Причина: MTU 1500 + Geneve 50 = 1550 > underlay 1450.
Решение: Cilium MTU = 1400. Проверено curl с ВМ (30/30).
### Фаза 2: HTTP/2
HTTP/2 → `ERR_HTTP2_PROTOCOL_ERROR`. HTTP/1.1 → `ERR_CONNECTION_RESET`.
Решение: `use-http2: "false"`.
### Фаза 3: Поиск корневой причины RST
Баг: браузер → свежая страница → POST 63KB → `ERR_CONNECTION_RESET`.
## Полная матрица тестов
| # | Сценарий | Результат |
|---|----------|-----------|
| 1 | Браузер: свежая страница → POST 63KB | ❌ RST / `⏳ 100%` hang |
| 2 | Браузер: свежая страница → GET (до Flask) → POST 63KB | ✅ 200 OK |
| 3 | Браузер: свежая страница → POST 10B → POST 63KB | ✅ 200 OK |
| 4 | Браузер: свежая страница → GET (nginx сам, 405) → POST 63KB | ❌ RST |
| 5 | `curl` с ВМ (5.172.178.213) → POST 63KB | ✅ всегда <100ms |
| 6 | Сразу после `kubectl rollout restart` ingress → браузер | ✅ работает |
| 7 | Через 5-10 мин после рестарта → браузер | ❌ снова RST |
## Что исключено
| Гипотеза | Статус | Почему |
|----------|--------|--------|
| MTU | ❌ исключено | 1400, curl с ВМ всегда работает |
| HTTP/2 | ❌ исключено | `use-http2: false`, та же проблема |
| XHR vs fetch | ❌ исключено | оба метода падают одинаково |
| `proxy_request_buffering: off` | ❌ исключено | `on` для `location /` |
| `client-body-timeout: 10` | ❌ исключено | 63KB < 1 сек |
| FD/worker-лимиты | ❌ исключено | 1M FD, 16K connections, 62MB mem |
| OOM/Restart | ❌ исключено | 0 рестартов, нет OOMKilled |
| Stale upstream keepalive | ❌ исключено | `keepalive=0` не помогло |
| nginx→Flask (upstream) | ❌ исключено | curl с ВМ доказывает что работает |
## Эксперименты
### Перезапуск ingress временно чинит
После `kubectl rollout restart` — браузер работает. Через 5-10 мин — снова RST. Накопление состояния.
### Ресурсы в норме
```
worker_rlimit_nofile: 1047552 (1M FD)
worker_connections: 16384
worker_processes: 4
Memory: 62 MiB, CPU: 3-7m
Restarts: 0, OOMKilled: нет
```
### `upstream-keepalive-connections: 0` — НЕ фикс
Баг вернулся через 5-10 минут. Keepalive pool не при чём.
## Критическое противоречие
- `curl` с ВМ (тот же датацентр) → POST 63KB → **всегда 200 OK <100ms**
- Браузер через 5-10 мин после рестарта ingress → **ERR_CONNECTION_RESET**
curl и браузер идут через один VIP (185.247.187.151), на одни ingress-поды.
Разница: клиент (curl vs Chrome) и сетевой путь (локальная сеть ДЦ vs интернет/VPN).
## Текущая конфигурация (2026-07-14)
| Параметр | Значение |
|----------|----------|
| `use-http2` | `"false"` |
| `upstream-keepalive-connections` | `"0"` |
| `error-log-level` | `debug` |
| `proxy-body-size` | `1024m` |
| `client-body-timeout` | `"10"` |
| `keep-alive` | `"10"` |
| Cilium MTU | `1400` |
| `externalTrafficPolicy` | `Cluster` |
| Ingress controller | `1.12.6` (Штурвал) |
---
## Вопрос к Claude (Sonnet/Opus)
### Симптом
Браузер (Chrome/Electron, Windows) → POST 63KB multipart/form-data → `ERR_CONNECTION_RESET` или зависание на `⏳ 100%`. Тело уходит полностью, ответ не приходит — TCP RST.
### Окружение
```
Кластер: bare-metal K8s 1.34.1, 4 ноды (3 worker + 1 control-plane)
CNI: Cilium Geneve, MTU подов = 1400
VIP: kube-vip 185.247.187.151 (ARP на все 4 ноды)
Ingress: nginx-ingress (Штурвал Helm), контроллер 1.12.6
2 реплики, externalTrafficPolicy: Cluster
Приложение: Flask/Waitress, 1 реплика (Штурвал managed)
```
Конфиг ingress:
```yaml
use-http2: "false"
upstream-keepalive-connections: "0"
client-body-timeout: "10"
keep-alive: "10"
worker-processes: 4
worker-rlimit-nofile: 1047552
worker-connections: 16384
proxy-body-size: 1024m
proxy-request-buffering: on # для location /
error-log-level: debug
```
### Критическое противоречие
- `curl` с ВМ (5.172.178.213, тот же датацентр) → POST 63KB → **всегда 200 OK <100ms**
- Браузер через 5-10 мин после рестарта ingress → **ERR_CONNECTION_RESET**
- Сразу после рестарта ingress → браузер работает
curl и браузер идут через один VIP (185.247.187.151), на одни ingress-поды. Разница: клиент (curl vs Chrome) и сетевой путь до VIP (локальная сеть ДЦ vs интернет/VPN).
### Вопросы
1. Почему рестарт ingress помогает на 5-10 минут, а потом браузерные запросы снова получают RST, при том что curl с ВМ продолжает работать?
2. Может ли `externalTrafficPolicy: Cluster` + kube-vip создавать conntrack-записи, которые со временем ломают соединения из интернета (с другими TCP options/MSS) но не из локальной сети?
3. Стоит ли попробовать `externalTrafficPolicy: Local`? Какие риски?
4. Какие ещё эксперименты можно провести без tcpdump (нет доступа к нодам по SSH, нет tcpdump в подах)?
---
## TODO
- [ ] Ответ от Claude
- [ ] `externalTrafficPolicy: Local` — эксперимент
- [ ] Воспроизвести с `curl` из WSL на машине пользователя
- [ ] tcpdump через `kubectl debug` (требует координации)
- [ ] Обновить ingress controller 1.12.6 → актуальный
- [ ] Проверить `client-body-timeout: 60` + `keep-alive: 75`
---
## Ответ Claude (Sonnet) — 2026-07-14
### Два независимых механизма
Симптом объясняется наложением двух механизмов:
### Механизм 1: kube-vip ARP flapping + conntrack asymmetry (основной)
Именно это объясняет 5-10-минутное окно и разницу между browser и curl.
**Как работает с `externalTrafficPolicy: Cluster`:**
```
Browser → VIP → Node N1 (kube-vip leader) → iptables DNAT+SNAT → nginx pod
└── conntrack entry создаётся на N1:
(browser_ip:srcport → 185.247.187.151:443)
↔ (N1_ip:eph → nginx_pod:443)
```
Проблема возникает когда kube-vip меняет лидера:
```
kube-vip: N1 → N2 (истёк lease или GARP от N2)
Коммутатор: обновляет ARP-кэш, VIP теперь на N2
Browser (старое HTTP keep-alive соединение):
→ PSH/ACK 63KB → попадает на N2
N2: нет conntrack записи → TCP RST
curl (новое соединение):
→ SYN → попадает на N2
N2: создаёт новую conntrack → всё ОК
```
**Почему 5-10 минут:** время жизни ARP-записи на коммутаторе или интервал переизбрания kube-vip лидера. "ARP на все 4 ноды" — периодические GARP-анонсы могут уходить с разных нод.
**Почему GET-to-Flask чинит последующий POST (тесты #2 vs #4):**
- GET → Flask: создаётся *новое* TCP-соединение (новый SYN) → правильный conntrack на текущей ноде. POST идёт по *тому же* соединению → conntrack жив → OK.
- GET → 405: nginx отвечает сам и закрывает соединение с `Connection: close`. POST создаёт *новое* соединение, но VIP уже на другой ноде → RST.
### Механизм 2: `keepalive_timeout: 10s` — слишком агрессивно
`keep-alive: "10"` в ConfigMap = `keepalive_timeout 10s` в nginx.
```
T=0: Browser загружает страницу (GET /)
T=10: nginx закрывает idle соединение (отправляет FIN)
T=?: Пользователь заполняет форму (>10 сек → почти всегда)
T=X: Browser отправляет POST по "живому" с его точки зрения соединению
→ nginx уже закрыл socket → RST
```
Браузер не всегда замечает FIN немедленно (TCP буферизация ОС). curl — каждый раз новое соединение.
### Почему curl всегда работает
| | Browser | curl |
|---|---|---|
| Соединение | HTTP keep-alive, долгоживущее | Новое для каждого запроса |
| conntrack | Создано при SYN, может устареть | Новый SYN на каждый вызов |
| keepalive | Держит idle >10s | Сразу POST → не ждёт |
| VIP flap | Пакет идёт на новую ноду без conntrack | SYN на новой ноде создаёт conntrack |
### Ключевой инсайт
> Разница не в TCP-опциях (MSS, window size). Разница в **типе пакета**: curl всегда начинает с SYN → conntrack создаётся на правильной ноде. Browser держит соединение между запросами → когда VIP мигрирует, браузерные пакеты (ACK/PSH) приходят на новую ноду без предшествующего SYN → нет conntrack-записи → RST.
### Итоговая таблица причин
| Причина | Механизм | Почему не curl |
|---|---|---|
| kube-vip ARP lease renewal | Смена ноды → conntrack orphan | Новый SYN всегда |
| `keepalive_timeout: 10s` | nginx закрывает idle conn → browser не замечает | Нет idle |
Оба фактора вместе: рестарт сбрасывает состояние, 5-10 минут → kube-vip переизбирается / браузер держит соединение дольше 10s → RST.
### Рекомендации
1. **`keep-alive: "75"`** — исключить механизм #2 (самое простое, безопасное)
2. **`externalTrafficPolicy: Local`** — исключить механизм #1 (риск: потеря source IP для других сервисов)
3. **JS: Connection warmer** — GET на Flask при загрузке страницы (уже доказано тестом #2)
4. **JS: пересоздавать соединение** — использовать `fetch()` без keep-alive (credentials: 'omit' или отдельный subdomain)
@@ -0,0 +1,23 @@
# Waitress + Connection: close — hop-by-hop заголовок
**Дата:** 2026-07-14
**Версия:** 0.0.21 → 0.0.22
---
## Ошибка
`Connection` — hop-by-hop заголовок. Waitress (WSGI) запрещает его использовать в приложении (PEP 3333).
```python
# ❌ НЕЛЬЗЯ
response.headers["Connection"] = "close"
# Ошибка:
AssertionError: Connection is a "hop-by-hop" header; it cannot be used by a WSGI application (see PEP 3333)
```
## Правило
**Никогда не добавлять `Connection` заголовок в Flask/Waitress-приложении.**
Нужно закрывать соединения — только через nginx (upstream keepalive) или uWSGI/gunicorn.
+48
View File
@@ -0,0 +1,48 @@
# SSE: отлов отключения клиента, v0.0.18
**Дата:** 2026-07-14
**Версия:** 0.0.17 → 0.0.18
---
## Проблема
После 2-3 F5 во время обработки — загрузка падает с «Ошибка загрузки: Сеть».
Причина: утечка потоков.
### Механика
1. `process_stream` создаёт SSE-генератор, занимающий поток Waitress (всего 8)
2. `obfuscate_files()` выполняется ~30 сек (LLM-запросы)
3. Пользователь делает F5 → TCP-соединение рвётся
4. Генератор **не знает** что клиент отключился — продолжает обрабатывать файлы
5. Поток занят, не освобождается
6. 2-3 F5 → все 8 потоков заняты → новые запросы не принимаются → «Сеть»
### Почему WSGI не отключает
`yield` в генераторе пишет в буфер WSGI, а не в сокет. Буфер никуда не уходит
(клиент отключён), но генератор об этом не узнаёт — `GeneratorExit` не кидается.
---
## Решение
Добавлен `try/except GeneratorExit` после каждого `yield` в `generate()`:
```python
try:
yield f"event: done..."
except GeneratorExit:
return # клиент отключился, освобождаем поток, НЕ обрабатываем остальные файлы
```
Waitress при отключении клиента кидает `GeneratorExit` при попытке записи в
закрытый сокет. Генератор ловит → выходит → поток освобождается.
---
## Изменённые файлы
- `site/routes/api_bp.py``try/except GeneratorExit` в `generate()`
- `site/app.py``VERSION = "0.0.18"`
+158
View File
@@ -0,0 +1,158 @@
# Cluster Dump — 2026-07-15 17:00 MSK
## 1. НОДЫ (4 шт)
| Нода | Роль | Taints |
|------|------|--------|
| iot-naeel-control-plane-xb699 | control-plane | `NoSchedule:control-plane` |
| iot-naeel-workers-vqphm-6f74n | workers | нет |
| iot-naeel-workers-vqphm-bhbvs | workers | нет |
| iot-naeel-workers-vqphm-v8zq4 | workers | нет |
## 2. INGRESS (shturval-ingress-controller)
### Поды (2 реплики)
| Под | Нода | Возраст |
|-----|------|---------|
| ...86jsp | control-plane-xb699 | ~6ч |
| ...jm4kl | workers-vqphm-v8zq4 | ~6ч |
### Сервис
- Тип: LoadBalancer
- External IP: 185.247.187.151
- `externalTrafficPolicy: Cluster`
- NodePorts: 80:30739, 443:32391
### Ingress ConfigMap
| Параметр | Значение |
|----------|----------|
| keep-alive | **10** |
| client-body-timeout | **10** |
| client-header-timeout | **10** |
| proxy-body-size | **8m** |
| upstream-keepalive-connections | **0** |
| use-http2 | false |
| worker-processes | 4 |
| error-log-level | info |
### Helm
- Chart: shturval-ingress-controller-2.12.1
- Ревизия 9 (сегодня 11:53) — `replicaCount: 2`, hostPort enabled
- Ревизия 8 (сегодня 02:00) — то же самое
- Более старых ревизий нет (почищены)
### Affinity (ingress deploy)
```yaml
preferredDuringScheduling:
- weight: 10 → избегать control-plane
- weight: 80 → предпочитать node-role: ingress
- weight: 50 → предпочитать node-role: infra
```
### События (каждые 51 сек!)
```
Warning SyncLoadBalancerFailed failed to ensure load balancer: no address pools could be found
Normal EnsuringLoadBalancer Ensuring load balancer
```
## 3. KUBE-VIP
### DaemonSet: shturval-vip (4 пода, на ВСЕХ 4 нодах)
- Image: r.shturval.tech/kube-vip:v1.0.0
- ARP mode: `vip_arp: true`
- Election: `svc_election: true`
- Leaderelection: `vip_leaderelection: false`
- Lease: `sht-uservip-cp-lock`
### Cloud Provider: shturval-vip-provider (1 под, на bhbvs)
- Image: r.shturval.tech/kubevip/kube-vip-cloud-provider:v0.0.12
- Ищет ConfigMap: `kubevip` в неймспейсе `kube-system`
### ❌ ConfigMap `kubevip` в `kube-system` — **ПУСТОЙ!**
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: kubevip
namespace: kube-system
data: {} # <-- НЕТ ДАННЫХ
```
**Это причина ошибки «no address pools could be found»!**
## 4. DRHIDER (pythonk8s)
### Деплой
- 1 реплика
- Ревизия: 49
- Инит-контейнер: клонирует из Git, потом pip install + python app.py
- Ресурсы: 500m CPU, 1Gi RAM
- Проба: TCP :5000
### ❌ PodAffinity (добавлен сегодня 16:46, непрошеный)
```yaml
requiredDuringScheduling:
podAffinity → ingress pod на той же ноде
```
### Сервис
- ClusterIP: 10.102.171.63
- Port 80 → targetPort 5000
- `internalTrafficPolicy: Cluster`
### Ingress (drhider)
- Host: drhider.pythonk8s.dev.nubes.ru
- Аннотации: proxy-body-size=1024m, connect=120s, read/send=600s
- TLS: letsencrypt-prod
### Поды
| Под | Нода | Возраст |
|-----|------|---------|
| pythonk8s-6698c6c78-dkwsq | workers-vqphm-v8zq4 | ~15 мин |
Drhider на одной ноде с ingress-подом jm4kl.
## 5. ВСЕ INGRESS-РЕСУРСЫ
| Хост | Неймспейс | Возраст |
|------|-----------|---------|
| drhider.pythonk8s.dev.nubes.ru | 20a75175-... | 4д |
| loadtest.pythonk8s.dev.nubes.ru | 9039a501-... | 5д |
| contractor.pythonk8s.dev.nubes.ru | b4523aba-... | 7ч |
| capire.kube5s.ru | default | 54д |
| grafana.kube5s.ru | grafana | 66д |
| keycloak.k8c.ru | keycloak | 96д |
| qu.kube5s.ru | shared-sqs | 93д |
| iot.kube5s.ru | sless | 94д |
| terra.k8c.ru | terra | 92д |
## 6. CILIUM
4 пода (по одному на ноду), все Running. MTU был изменён на 1400 (по STATE).
## 7. КЛЮЧЕВЫЕ НАХОДКИ
### 🔴 ConfigMap `kubevip` пустой
kube-vip-cloud-provider не может найти address pools → каждые 51 сек ошибка SyncLoadBalancerFailed. При этом `185.247.187.151` работает через ARP-mode kube-vip DaemonSet (не через cloud provider). Вероятно, это не fatal, но указывает на недонастроенную интеграцию.
### 🔴 Ingress ConfigMap отличается от ожидаемого
- keep-alive: 10 (должен быть 75)
- client-body-timeout: 10 (должен быть 60)
- client-header-timeout: 10 (должен быть 30)
- proxy-body-size: 8m (должен быть 1024m)
Но для drhider это переопределено в аннотациях ingress-ресурса.
### 🟡 Ingress 2 реплики на 4 нодах с kube-vip
kube-vip анонсирует 185.247.187.151 на ВСЕХ 4 нодах, но ingress-поды только на 2. При `externalTrafficPolicy: Cluster` трафик на ноды без ingress: SNAT → кросс-нода → риск conntrack RST.
### 🟡 Helm: 2 апгрейда сегодня
Ревизии 8 (02:00) и 9 (11:53) сегодня. Кто-то обновлял ingress. `replicaCount: 2` в обеих.
### 🟢 Аннотации drhider ingress — хорошие
proxy-body-size=1024m, connect=120s, read/send=600s. Переопределяют дефолты ConfigMap.
### 🟢 Cilium — стабильный
4 пода, все Running, MTU 1400.
+204
View File
@@ -0,0 +1,204 @@
# DrHider — итоговая диагностика ERR_CONNECTION_RESET
**Дата:** 2026-07-15 (финал)
**Версия:** v0.0.29
---
## Резюме (моё мнение)
**`keep-alive: 10` — единственная причина ERR_CONNECTION_RESET.** Всё остальное (conntrack, hostPort, ARP flapping, svc_election) — шум, который мы искали 2 дня.
### Почему это так
VIP `185.247.187.151` жёстко привязан к control-plane-xb699 через аннотацию `kube-vip.io/vipHost`. Никакого flapping нет — трафик всегда приходит на одну ноду. Второй ingress-под на v8zq4 — избыточен, но не мешает.
Сценарий:
1. Браузер загружает страницу — opens TCP
2. Пользователь выбирает файлы — проходит >10 сек
3. Nginx (keep-alive=10) закрывает idle соединение
4. Браузер не замечает FIN (буферизация ОС)
5. POST 63KB летит в мёртвый сокет → RST
### Почему предыдущие гипотезы неверны
| Гипотеза | Опровержение |
|----------|-------------|
| kube-vip ARP flapping | `vipHost` фиксирует VIP на одной ноде. Lease мёртв — плевать |
| hostPort на 2 из 4 нод | Трафик не на все 4 ноды, а только на control-plane |
| upstream keepalive stale | `keepalive=0` не помог |
| MTU | Решено ещё 14-го, не при чём |
| conntrack | ETPolicy Cluster не при чём — трафик на одну ноду |
### Что подтверждает keep-alive гипотезу
| Тест | Объяснение |
|------|-----------|
| Сразу после рестарта ingress → OK | Все соединения свежие |
| Через 5-10 мин → RST | Соединения постарели >10с |
| GET/health → POST → OK | Новое живое соединение |
| curl с ВМ → всегда OK | curl открывает новый SYN каждый раз |
| `keep-alive: 75` + JS warmup → OK на 10+ мин | Увеличили окно, warmup греет |
## Хронология
| Дата | Событие |
|------|---------|
| 2026-07-11 | Исходный деплой |
| 2026-07-14 (день) | MTU fix (Cilium 1400) — решило 51с задержку |
| 2026-07-14 (вечер) | RST диагностика: keep-alive 75 + JS warmup — частично |
| 2026-07-15 (11:53) | Helm ревизия 9 — сброс ConfigMap на дефолты (keep-alive:10) |
| 2026-07-15 | podAffinity `required` на drhider (ручной kubectl edit) |
| 2026-07-15 (16:00) | Полная диагностика: hostPort vs cloud LB |
---
## Две независимые проблемы
### Проблема #1: MTU (решена 2026-07-14)
**Симптом:** 51с задержка при POST любого размера через внешний VIP.
**Причина:** Geneve +50, underlay 1450, дефолт 1500 → фрагментация/дроп.
**Решение:** Cilium `mtu: 1400`.
### Проблема #2: ERR_CONNECTION_RESET (хост порт, решена 2026-07-15)
**Симптом:** Браузер получает TCP RST при POST 63KB через 5-10 мин после рестарта ingress.
**Ранее считалось:** keep-alive 10с или kube-vip conntrack.
**Реальная причина:** Штурвал балансирует трафик на **все 4 ноды**, но ingress стоит `replicas: 2` с **hostPort** — порт 80/443 открыт только на 2 нодах.
```
Browser → cloud LB → любая из 4 нод
├── нода с ingress-подом → hostPort 80 → nginx → ✅
└── нода БЕЗ ingress-пода → порт 80 не слушается → RST ❌
```
**50% запросов попадает на пустую ноду → RST.**
---
## Подтверждающие данные
### Helm ревизии (дамп)
| Ревизия | Время | replicaCount | Другие изменения |
|---------|-------|-------------|------------------|
| 1 (деплой) | 2026-07-11 | 2 | — |
| 8 | 2026-07-15 02:00 | **2** | — |
| 9 | 2026-07-15 11:53 | **2** | — |
Helm **не менял** replicaCount. Всегда 2. Но ConfigMap сбрасывал на дефолты (keep-alive: 10, proxy-body-size: 8m и т.д.).
### Ingress ConfigMap (последствия Helm upgrade)
```yaml
# Было (ручные правки 2026-07-14) → Стало (после ревизии 9)
keep-alive: "75" → "10" # вернулось на дефолт Штурвала
proxy-body-size: "1024m" → "8m" # тоже сброшено
error-log-level: debug → info # сброшено
upstream-keepalive-connections: "0" → сохранилось
```
### kube-vip svc_election — мёртв
```yaml
# Lease ingress/kubevip-shturval-ingress-controller-controller
holderIdentity: "" # пусто — никто не держит
leaseDurationSeconds: 1 # аномально короткий (норма 15-30)
renewTime: 2026-07-11T14:55:28Z # 4 дня назад не обновлялся
leaseTransitions: 17 # но было 17 переходов
```
svc_election никогда не работал. VIP назначен через `ipMode: VIP` в статусе сервиса, работает на уровне облака (BGP/маршрутизация).
### hostPort
```yaml
# В шаблоне пода ingress (Helm template)
hostPort: 80
hostPort: 443
```
Включён в ревизиях 8 и 9. hostPort = порт слушается ТОЛЬКО на нодах где стоит под.
### podAffinity на drhider — временный костыль
```json
{
"podAffinity": {
"requiredDuringSchedulingIgnoredDuringExecution": [{
"labelSelector": {"matchLabels": {"app.kubernetes.io/name": "shturval-ingress-controller"}},
"namespaceSelector": {"matchLabels": {"name": "ingress"}},
"topologyKey": "kubernetes.io/hostname"
}]
}
}
```
Добавлен через `kubectl edit`, НЕ через Helm. Привязывает drhider к ноде с ingress-подом. Это НЕ лечит RST (RST на уровне VIP→нода, не на уровне нода→drhider). При следующем `helm upgrade` — исчезнет.
### Kube-vip DaemonSet — не участвует
```yaml
vip_arp: true # ARP включён
vip_leaderelection: false # нет CP election
svc_election: true # должен быть — но lease мёртв
```
Даемоны не логгируют VIP `185.247.187.151`. Трафик распределяет облако, не kube-vip.
---
## Решение
**Единственное полное решение:** ingress-под на каждой ноде куда приходит трафик.
### Вариант 1 (рекомендуемый): увеличить replicas
```yaml
# Helm values
shturval-ingress-controller:
replicaCount: 4
```
Или выяснить у DevOps сколько нод в LB-пуле Штурвала и поставить `replicaCount = число нод`.
### Вариант 2 (если балансировка на все worker'ы): DaemonSet
```yaml
# Helm values
shturval-ingress-controller:
kind: DaemonSet
```
---
## Что делать сейчас (ручной воркараунд)
### 1. Починить ConfigMap (срочно)
```bash
kubectl patch configmap -n ingress shturval-ingress-controller-controller --type merge \
-p '{"data":{"keep-alive":"75","proxy-body-size":"1024m","client-body-timeout":"60","client-header-timeout":"30"}}'
```
### 2. Масштабировать ingress (временный фикс)
```bash
kubectl scale deploy -n ingress shturval-ingress-controller-controller --replicas=4
```
Поды раскидаются по всем 4 нодам → hostPort на всех → 0% RST.
⚠️ **Предупреждение:** после следующего `helm upgrade`:
- ConfigMap сбросится (нужно править Helm values)
- replicas вернётся на 2
- podAffinity на drhider исчезнет
---
## Вопросы к DevOps
1. **Сколько нод в LB-пуле Штурвала?** (на какие ноды облако направляет трафик ingress?)
2. **Можно ли увеличить `replicaCount` до числа нод?**
3. **Как правильно изменить Helm values для ingress?** (чтобы ConfigMap не сбрасывался при upgrade)
+135
View File
@@ -0,0 +1,135 @@
# Проблема загрузки файлов: диагностика и решение
**Дата:** 2026-07-16
**Версия приложения:** v0.0.31
**Кластер:** `iot-naeel` (4 ноды, Cilium Geneve, kube-vip, Штурвал)
---
## Проблема 1: 51-секундная задержка (MTU)
### Суть
При передаче POST из внешней сети — пауза 51с на любом объёме данных. Внутри кластера — мгновенно.
### Причина
Underlay MTU = 1450. Cilium Geneve добавляет 50 байт. Дефолтный MTU пода = 1500.
Cross-node пакет = 1500+50 = 1550 → фрагментация → дроп → ядро ждёт повторной передачи 51с.
### Решение
`cilium-config``mtu: 1400`. 1400+50 = 1450 = underlay.
Задокументировано 2026-07-13, применено 2026-07-14.
---
## Проблема 2: ERR_CONNECTION_RESET после фикса MTU
### Суть
curl с ВМ работает. Браузер — `ERR_CONNECTION_RESET` или `⏳ 100%`.
После `rollout restart` ingress — работает 5-10 минут, потом снова RST.
### Ложные следы (опровергнуты)
| Гипотеза | Почему неверна |
|----------|---------------|
| kube-vip ARP flapping | `kube-vip.io/vipHost: control-plane` — VIP жёстко на одной ноде |
| hostPort на 2 из 4 нод | Трафик только на control-plane, не на все 4 |
| upstream keepalive stale | `upstream-keepalive-connections: 0` не помог |
| conntrack | ETPolicy Cluster не при чём — трафик на одну ноду |
| MTU | Решено в проблеме 1 |
| HTTP/2 | `ERR_HTTP2_PROTOCOL_ERROR` → отключили → `ERR_CONNECTION_RESET` — симптом тот же |
### Истинная причина
**`keep-alive: 10`** — Штурвал ставил nginx `keep-alive: 10` (вместо дефолта 75).
Сценарий:
1. Браузер грузит страницу — TCP открыт
2. Пользователь выбирает файлы (>10с) — nginx закрывает idle соединение
3. Браузер не замечает FIN (буферизация ОС)
4. POST 63KB летит в закрытый сокет → TCP RST
### Решение (применено в платформе Штурвала 2026-07-15)
Через `ShturvalServiceConfig` (не kubectl — ArgoCD откатывает):
```yaml
controller:
config:
keep-alive: "75"
proxy-body-size: "1024m"
client-body-timeout: "60"
client-header-timeout: "30"
hostPort:
enabled: true
replicaCount: 2
service:
type: LoadBalancer
```
Также в коде — `fetch('/health')` при загрузке страницы (прогрев upstream).
### v0.0.30 — .doc в диалоге выбора файлов
Добавлен `accept=".doc,.docx,.pdf,.txt,.zip"` в `<input type="file">`.
`doc_to_markdown()` переписан: вместо `soffice` (LibreOffice на ВМ) — HTTP-вызов `liberta.containerk8s.dev.nubes.ru/convert` через `httpx`.
### v0.0.31 — .doc обрабатывается обфускацией
`.doc` теперь не просто конвертируется, а проходит полную обфускацию (замена сущностей, mapping.csv). В архиве — `.md` с заменённым текстом, не исходный `.doc`.
### Результаты тестов (v0.0.29, 2026-07-15)
| Тест | Результат |
|------|-----------|
| 10× POST 63KB (свежие страницы) | **10/10 OK:200** |
| UI: 5 файлов + SSE + ZIP + CSV | ✅ полный цикл |
| 5× POST через 75с (keep-alive окно) | **5/5 OK:200** |
| 5MB PDF через curl | ✅ 200 OK, 0.62с |
| **ИТОГО** | **25/25 успешно, 0 RST** |
---
## Итоговая конфигурация кластера
### Ingress (через платформу Штурвала, несбрасываемо ArgoCD)
| Параметр | Дефолт nginx | Было (Штурвал) | Сейчас |
|----------|-------------|----------------|--------|
| `keep-alive` | 75 | **10** | **75** |
| `proxy-body-size` | 8m | 8m | **1024m** |
| `client-body-timeout` | 60 | **10** | **60** |
| `client-header-timeout` | 60 | **10** | **30** |
| `use-http2` | true | true | **false** |
| `upstream-keepalive-connections` | 0 | 50 | 0 |
| `error-log-level` | info | info | **debug** (вернуть info) |
### Cilium (через kubectl, не сбрасывается)
| Параметр | Дефолт | Сейчас |
|----------|--------|--------|
| `mtu` | 1500 | **1400** |
### Приложение (drhider v0.0.29)
```python
# site/app.py
VERSION = "0.0.29"
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB
```
```javascript
// site/templates/index.html — warmup при загрузке
window.addEventListener('load', () => {
sf = []; fi.value = ''; rr();
fetch('/health').catch(() => {}); // прогрев upstream
});
```
---
## Документация
| Файл | Что |
|------|-----|
| `docs/ARCHITECTURE.md` | Архитектура + требования к Flask |
| `docs/MIGRATION-GUIDE.md` | Инструкция переноса UI на Flask |
| `History/2026-07-14-browser-rst-debug.md` | Полная история диагностики RST |
| `History/2026-07-15-rst-root-cause.md` | Финальный диагноз |
| `History/2026-07-15-cluster-dump.md` | Дамп кластера |
| `STATE-2026-07-15.md` | Состояние проекта на 2026-07-15 |
+142
View File
@@ -0,0 +1,142 @@
# DrHider — обфускация документов (Managed Flask)
**URL:** https://drhider.pythonk8s.dev.nubes.ru/
**Репозиторий:** https://gitea.services.ngcloud.ru/Nail/drhider
**Платформа:** Штурвал (Managed Flask на pythonk8s)
**Дизайн фронтенда:** `/home/naeel/nubes/design/`
---
## Что делает
Загружаешь документы (.docx, .pdf, .txt, .zip) — получаешь ZIP с обфусцированными копиями.
Все персональные данные, реквизиты, телефоны, email заменяются на фиктивные.
В архиве — mapping.csv с таблицей замен (оригинал → фиктивное).
Обфускация двухпроходная:
1. **Проход 1 (сбор):** regex + LLM находят все сущности во всех файлах → глобальный словарь замен
2. **Проход 2 (замена):** применяем замены ко всем файлам → ZIP
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
---
## ⛔ ПРАВИЛА ДЛЯ АГЕНТОВ (читать перед ЛЮБЫМ действием)
### 1. НЕ смотреть локальные папки contracts/contracts-flask
Весь старый код drhider — ТОЛЬКО на ВМ (`5.172.178.213`). НЕ локально.
Единственное исключение: `/home/naeel/nubes/loadtest/` (образец структуры managed Flask).
### 2. Штурвал запускает Flask САМ
- БЕЗ Dockerfile (удалён)
- БЕЗ gunicorn (нет в requirements.txt)
- Точка входа: `site/app.py``app = create_app()`
- Деплой: `git push origin master` → авто-деплой в UI Штурвала
### 3. НЕ менять код без «делай»
Даже если ошибка очевидна. Показать → описать → ЖДАТЬ.
### 4. После ЛЮБОЙ правки:
```bash
python3 -c "import py_compile; py_compile.compile('файл.py', doraise=True)"
git add -A && git commit -m "подробное описание" && git push
```
### 5. Документировать ВСЁ в History/
Каждое изменение, план, ошибку — в отдельный .md файл.
---
## Структура проекта
### `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` содержит `ENTITY_GENERATORS` — маппинг `entity_type → генератор`.
### `site/` — Flask-приложение
| Файл | Назначение |
|---|---|
| `app.py` | `create_app()` — создание Flask, регистрация blueprint'ов, VERSION |
| `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 |
| `templates/index.html` | UI (выбор файлов, прогресс, скачивание) |
| `static/favicon.svg` | Иконка |
### Корневые файлы
| Файл | Назначение |
|---|---|
| `requirements.txt` | `flask`, `python-docx`, `pdfplumber`, `httpx` |
| `.gitignore` | `__pycache__/`, `*.pyc`, `.env` |
| `.gitea/workflows/deploy.yaml` | CI: валидация Python |
| `History/` | Документация всех изменений |
| `README.md` | Этот файл |
| `docs/ARCHITECTURE.md` | Архитектура |
| `docs/DEPLOY.md` | Деплой, ВМ, переменные окружения |
| `docs/DEVELOPMENT.md` | Порядок действий при изменении кода |
---
## API
| Метод | Путь | Что делает |
|---|---|---|
| `GET` | `/` | HTML-интерфейс |
| `GET` | `/health` | `{"ok": true, "version": "2.0.0"}` |
| `POST` | `/api/drhider` | multipart/form-data (поле `files`) → `application/zip` |
---
## Переменные окружения (Штурвал)
| Переменная | Значение |
|---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` |
| `LLM_MODEL` | `gpt-oss-120b` |
---
## Связь с ВМ contracts (legacy)
На ВМ `5.172.178.213` (contracts.kube5s.ru) есть старая версия drhider:
- `drhider_server.py` на порту 8767 — standalone HTTP-сервер
- `drhider.py` — та же логика, что и в этом проекте (скопирована оттуда)
Этот проект (`drhider.pythonk8s.dev.nubes.ru`) — **независимый managed Flask**, заменяет ВМ-версию.
SSH к ВМ: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`
+107
View File
@@ -0,0 +1,107 @@
# DrHider — состояние на 2026-07-15
## Текущая версия: v0.0.29 (задеплоена)
## Что работает
- Загрузка файлов (.docx, .pdf, .txt, .zip)
- Обфускация через LLM (два прохода: regex + LLM NER)
- SSE-прогресс обработки
- Скачивание ZIP + CSV (имена из Content-Disposition, не хардкод)
- Выбор файлов добавляет (не стирает), с дедупликацией
- При F5/закрытии — XHR и EventSource обрываются
## Что НЕ работает (баг вернулся)
**ERR_CONNECTION_RESET / Таймаут 30с при загрузке из браузера.**
Причина: kube-vip ARP flapping + `externalTrafficPolicy: Cluster` + 4 ingress пода.
Браузерное keep-alive соединение попадает на ноду без conntrack → RST.
### Что пробовали
| Фикс | Результат |
|------|-----------|
| `keep-alive: 10 → 75` | Помогло, но временно |
| JS: `fetch('/health')` при загрузке (warmup) | Помогло, но временно |
| `upstream-keepalive-connections: 0` | Не помогло |
| Перезапуск ingress | Помогает на 5-10 мин, потом снова |
### Что НЕ пробовали (главный кандидат)
**`externalTrafficPolicy: Local`** — убирает лишний хоп kube-proxy, conntrack создаётся на той же ноде где ingress под. Это должно добить проблему навсегда.
Но затрагивает ВСЕ сервисы на этом ingress, не только drhider.
## Текущая конфигурация кластера
### Ingress ConfigMap
```
keep-alive: "75" # было 10 — Штурвал
client-body-timeout: "10" # было 10 — надо 60
client-header-timeout: "10" # было 10 — надо 30
use-http2: "false" # можно вернуть true
proxy-body-size: "1024m"
upstream-keepalive-connections: "0"
error-log-level: "debug" # вернуть info
```
### Cilium
```
mtu: 1400 # было 1500 — Geneve overhead
```
### Ingress поды (4 шт — что-то изменило кол-во)
```
control-plane: 172.16.0.73 (3h13m)
v8zq4: 172.16.3.149 (3h13m)
bhbvs: 172.16.2.115 (29m) ← новые
6f74n: 172.16.1.227 (29m) ← новые
```
### Drhider под
```
Неймспейс: 20a75175-a58c-49cb-b8fa-e86367b1a8dc
Deployment: pythonk8s (1 реплика)
```
## Ключевые файлы
| Файл | Что |
|------|-----|
| `site/templates/index.html` | Весь фронтенд (HTML/CSS/JS) |
| `site/routes/api_bp.py` | API: upload, process_stream (SSE), download, csv |
| `site/routes/health_bp.py` | `/health` — liveness probe |
| `site/app.py` | Точка входа Flask, VERSION, MAX_CONTENT_LENGTH, no_cache |
| `site/session.py` | In-memory сессии |
| `drhider/*.py` | Бизнес-логика обфускации |
| `docs/ARCHITECTURE.md` | Архитектура, требования Flask, имена файлов |
| `docs/MIGRATION-GUIDE.md` | Инструкция миграции UI на Flask |
| `PROBLEM-AND-SOLUTION.md` | MTU fix, конфигурация для DevOps |
| `History/2026-07-14-browser-rst-debug.md` | Полная история диагностики RST |
## Как деплоить
```bash
# После правок кода:
cd /home/naeel/nubes/drhider
# Bump VERSION в site/app.py
# Bump версию в index.html (два места: заголовок .ver и в JS если есть)
git add -A && git commit -m "..." && git push
ssh ... naeel@5.172.178.213 "kubectl rollout restart deploy -n 20a75175... pythonk8s"
```
## Что делать дальше
1. **Срочно:** перезапустить ingress чтобы пользователь мог работать сейчас:
```bash
kubectl rollout restart deploy -n ingress shturval-ingress-controller-controller
```
2. **Навсегда:** `externalTrafficPolicy: Local` — обсудить с DevOps
3. **Докупить:** обновить PROBLEM-AND-SOLUTION.md после применения Local
+34
View File
@@ -0,0 +1,34 @@
тип_данных,оригинал,замена
text,"""115404"," г. Москва, ул. 1-я Стекольная, д.7с2"",Красноярск_0001
"
text,"""АО """"****XXX****003****""""""","Альянс_0001
"
text,"""АО """"XXX003""""""","Стандарт_0001
"
text,"""ЗАО """"****XXX****001****""""""","Сервис_0001
"
text,"""ЗАО """"XXX001""""""","Прогресс_0001
"
text,"""ООО """"НУБЕС""""""","Меридиан_0001
"
phone,+7 (495) 789-41-35,"+7_000_000_0001
"
text,01300_2,"Договор_0004
"
text,044525225,"БИК_0001
"
text,FILE: допник-1-XXX002-01200_3.doc,ID_0001
email,info@nubes.ru,"email_0001
"
company,ЗАО «ХХХ001»,"Сервис_0001
"
inn_ul,ИНН 9706005293,"ИНН_0001
"
text,Исполнителем Заказчику Сторонами,"Кузнецов_0002
"
kpp,КПП 772401001,"КПП_0001
"
ks,Кор/сч 30101810400000000225,"КС_0001
"
ogrn,ОГРН 1207700098759,"ОГРН_0001
"
1 тип_данных оригинал замена
2 text "115404 г. Москва, ул. 1-я Стекольная, д.7с2",Красноярск_0001
3 text "АО ""****XXX****003****""" Альянс_0001
4 text "АО ""XXX003""" Стандарт_0001
5 text "ЗАО ""****XXX****001****""" Сервис_0001
6 text "ЗАО ""XXX001""" Прогресс_0001
7 text "ООО ""НУБЕС""" Меридиан_0001
8 phone +7 (495) 789-41-35 +7_000_000_0001
9 text 01300_2 Договор_0004
10 text 044525225 БИК_0001
11 text FILE: допник-1-XXX002-01200_3.doc ID_0001
12 email info@nubes.ru email_0001
13 company ЗАО «ХХХ001» Сервис_0001
14 inn_ul ИНН 9706005293 ИНН_0001
15 text Исполнителем Заказчику Сторонами Кузнецов_0002
16 kpp КПП 772401001 КПП_0001
17 ks Кор/сч 30101810400000000225 КС_0001
18 ogrn ОГРН 1207700098759 ОГРН_0001
19 company ООО «НУБЕС» Проект_0001
20 company ПАО Сбербанк Меридиан_0001
21 rs Р/с 40702810738000174030 РС_0001
22 text Степаненко В.С. Михайлов_0001
23 text Степаненко Василия Сергеевича Фёдоров_0001
24 text г. Москва Екатеринбург_0001
25 text г. Москва ул. 1я Стекольная д7 стр. 5 Нижний_Новгород_0001
26 text г. Москва ул. Бутлерова д. 7 Красноярск_0001
27 text договор-XXX001-03700.docx ID_0001
28 text спецификация-XXX003-01300_2.docx ID_0001
29 text № 01300_2 Договор_0003
30 text № 03700_1 Договор_0001
31 text №****1 Договор_0001
32 text №01200_3 Договор_0001
33 text №01300_2 Договор_0001
34 text №1 Договор_0002
+286
View File
@@ -0,0 +1,286 @@
**************Договор******** ****************на оказание технологических услуг******
Договор_0001
**Сервис_0001** именуемое в дальнейшем "Заказчик", в лице Генерального директора ФИО, действующего на основании Устава, и **ООО **"**НУБЕС**", именуемое в дальнейшем "Исполнитель", в лице Генерального директора Николаев_0001, действующего на основании Устава, с другой стороны, вместе именуемые "Стороны", заключили настоящий Договор о нижеследующем:
# ТЕРМИНЫ И ОПРЕДЕЛЕНИЯ
**Услуги **– оказываемые Исполнителем Заказчику технологические услуги в области компьютерных и телекоммуникационных технологий и информационной безопасности, размещение серверов на технической площадке (colocation), организация канала связи, обеспечение бесперебойного электропитания и охлаждения, предоставление готовой технической инфраструктуры, профессиональное техническое обслуживание оборудования в режиме 24/7/365, подключение к сети Интернет по высокоскоростным каналам связи, оказываемые Исполнителем Заказчику. Услуги подразделяются на Инсталляционные, Абонентские (ежемесячные), Тарифицируемые и разовые (дополнительные). Полный перечень и описание оказываемых Услуг приводятся в Спецификациях, утверждаемых Сторонами в виде Приложения №1 к Договору, являющегося его неотъемлемой частью (далее – «Спецификация»).
**Инсталляционные услуги** – Услуги по установке, настройке и включению в сетевую инфраструктуру Исполнителя оборудования и/или программного обеспечения, необходимого для предоставления Заказчику Абонентских и Тарифицируемых Услуг.
**Абонентские услуги** – Услуги, указанные в Спецификации, предоставляемые ежемесячно в течение всего срока действия Договора.
**Тарифицируемые услуги** – Услуги, оказываемые в дополнение к Абонентским, количество объектов обслуживания для которых непостоянно. Ежемесячная стоимость услуги определяется в конце каждого отчетного периода, в соответствии с ценой услуги за один объект обслуживания указанной в Спецификации.
**Разовые (дополнительные) услуги** – Услуги, которые предоставляются на основании письменного запроса Заказчика, оформленного в соответствии Регламентом взаимодействия Сторон в процессе оказания Услуг, утверждаемом Сторонами в виде Приложения №3 к настоящему Договору (далее – «**Регламент взаимодействия Сторон в процессе оказания Услуг**»).
**Этап оказания Услуг** - очередной этап исполнения обязательств по Договору, Отчетный период или период предоставления Услуг, предусмотренных Договором.
**Информационная система (далее – «ИС»)** – совокупность прикладного программного обеспечения и информационного наполнения, обладающая полезными функциями, необходимыми Заказчику. ИС может являться собственностью Заказчика или предоставляться ему в пользование Исполнителем на основании отдельно заключенного Сторонами соглашения.
**Облачные ресурсы – **виртуальные ресурсы ПАК (виртуальные серверы, резервное копирование данных), а также типовое программное обеспечение и специальное программное обеспечение, необходимое для выполнения конкретных функций. Состав и конфигурация предоставляемого ресурса и функционала ПАК Исполнителя приведены в Спецификации.
**Программно-аппаратный комплекс (далее – «ПАК»)** – совокупность оборудования и программного обеспечения, необходимого для оказания предусмотренных Договором Услуг.
**Технологическая площадка** – специализированное помещение, в котором размещается серверное и сетевое оборудование Исполнителя и предоставляются Услуги.
**Конфиденциальная информация -** означает всю информацию, не являющуюся общедоступной, предоставляемую одной Стороной другой Стороне в связи с настоящим Договором, за исключением той информации, которую Стороны определяют, как не конфиденциальная и которая не является конфиденциальной в соответствии с действующим законодательством РФ.
**Отчетный период -** означает календарный месяц.
**Рабочий день** означает день, официально считающийся в текущем году рабочим на территории Российской Федерации при пятидневной рабочей неделе.
**Уровень обслуживания** – совокупность параметров качества оказываемых Услуг, описывается в Соглашении об уровне обслуживания, утверждаемом Сторонами в виде Приложения № 2 к Договору (далее – «**Соглашение об уровне обслуживания**») и Приложении № 4 к Договору (далее – «**Технические условия**»), являющихся неотъемлемой частью Договора.
**УПД **- универсальный передаточный документ, подтверждающий выполнение работ и/или оказание Услуг Исполнителем.** **
**Криптовалютный майнинг** - процесс использования вычислительных мощностей для решения алгоритмических задач, направленных на создание новых блоков в блокчейне и получение вознаграждения в виде криптовалюты.
# ПРЕДМЕТ ДОГОВОРА
Исполнитель обязуется оказывать Заказчику технологические Услуги, указанные в Спецификации, а Заказчик обязуется принимать и оплачивать Услуги в объеме и в сроки согласно условиям настоящего Договора и Приложений к нему.
Место оказания услуг – технологическая площадка, Российская Федерация, г. Москва, 1-я Стекольная 7с5, г. Москва, 1-я Стекольная 7с6.
Период оказания Услуг по настоящему Договору, подлежащий оплате Заказчиком, начинается с даты подписания Сторонами Акта начала оказания Услуг или с даты начала Услуг, согласованной Сторонами в Спецификации, и заканчивается в момент истечения срока действия, прекращения или расторжения настоящего Договора, если иное не установлено настоящим Договором или письменно не согласованно Сторонами.
Технические требования к оказанию Услуг указаны в Соглашении об уровне обслуживания и Технических условиях.
# ПРАВА И ОБЯЗАННОСТИ СТОРОН
## Степанов_0001 определять количество специалистов и график их работы, объем вычислительных ресурсов и программного обеспечения, а также других ресурсов, необходимых для оказания Услуг. В случае необходимости Исполнитель имеет право привлекать для исполнения обязательств по настоящему Договору третьи стороны, при этом Исполнитель в полном объеме несет ответственность за действия привлеченных Исполнителем третьих лиц перед Заказчиком.
На основании Универсального передаточного документа (далее – «УПД»), подписываемого обеими Сторонами и счета на оплату, потребовать дополнительной оплаты затраченных ресурсов, если по вине или инициативе Заказчика в ходе исполнения настоящего Договора объем фактически оказанных Услуг превысил объем Услуг, предусмотренный условиями настоящего Договора.
Изменять в одностороннем порядке не чаще одного раза в 6 (шесть) месяцев цены на предоставляемые им Услуги с обязательным уведомлением Заказчика в письменной форме в срок не менее чем за тридцать календарных дней до вступления в силу новых цен. Отсутствие в течение вышеуказанного срока письменного отказа от новых цен со стороны Заказчика считается его согласием с указанными изменениями. Об изменении цен по Договору Стороны подписывают дополнительное соглашение. Заказчик обязуется в течение 15 рабочих дней с даты получения уведомления Исполнителя и Дополнительного соглашения об изменении цены по Договору подписать такое Дополнительное соглашение, либо направить Исполнителю письменный мотивированный отказ от его подписания. В случае отказа Заказчика от подписания Дополнительного соглашения на условиях, предлагаемых Исполнителем, а также в случае, если в указанный срок Заказчик не направит Исполнителю письменный мотивированный отказ от подписания Дополнительного соглашения, Исполнитель вправе расторгнуть настоящий Договор в порядке, предусмотренном п.15.11 настоящего Договора.
При оказании Услуг осуществлять взаимодействие с представителями Заказчика и доступ к оборудованию Заказчика, размещаемому на Технологической площадке для эксплуатационно-технического обслуживания в соответствии с Регламентом взаимодействия сторон в процессе оказания Услуг (Приложение №3 к настоящему Договору).
Запрашивать у Заказчика информацию (материалы, документы и сведения технического и организационного характера), необходимую для качественного и бесперебойного оказания Услуг.
По истечении 10 (десяти) календарных дней с момента приостановления оказания услуг или расторжения договора безвозвратно удалить все элементы информационной системы Заказчика, предоставляемые Исполнителем согласно Спецификации. Уведомление о приостановлении оказания услуг направляется в сроки, указанные в разделе 8 настоящего Договора. Уведомление о предстоящем расторжении договора в одностороннем порядке направляется в порядке, указанном в п.15.3. настоящего Договора.
В случае нарушения условий п. 9.2.11 Договора, а также при выявлении недостоверной информации, предоставленной Заказчиком Исполнитель вправе в одностороннем порядке расторгнуть Договор без возмещения Заказчику каких-либо затрат. Исполнитель не несет ответственности за убытки Заказчика, возникшие в связи с таким расторжением.
## Егоров_0001 у Исполнителя в дополнение к предусмотренным в Спецификациях иные Инсталляционные, Абонентские (ежемесячные), Тарифицируемые и разовые (дополнительные) Услуги.
Отказаться от ранее заказанных Тарифицируемых Услуг, письменно уведомив о своем намерении Исполнителя не позднее, чем за пять рабочих дней до даты прекращения указанных услуг. При этом Заказчик обязан оплатить Исполнителю стоимость фактически оказанных услуг.
Запрашивать у Исполнителя информацию о ходе выполнения Услуг.
Требовать сверки взаиморасчетов по Договору.
Осуществлять иные права по настоящему Договору.
Контролировать процесс оказания Исполнителем Услуг по настоящему Договору, не вмешиваясь в производственную и хозяйственную деятельность Исполнителя.
## Соколов_0001 Заказчику Услуги в объеме и в сроки, предусмотренные настоящим Договором и Приложениями к нему.
Принять в целях размещения на Технологической площадке Исполнителя оборудование Заказчика по Акту приема-передачи оборудования, подписываемому уполномоченными представителями Сторон в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг, и обеспечить условия его размещения и сохранности в соответствии с условиями Договора и Соглашения об уровне обслуживания.
Обеспечить работоспособность оборудования и программного обеспечения ПАК Исполнителя на Технологической площадке Исполнителя в соответствии с гарантиями и сертификатами их производителей на основании Соглашения об уровне обслуживания.
Вести учет потребления и оплаты Заказчиком Услуг, оказываемых в соответствии со Спецификацией.
Рассматривать запросы Заказчика на Услуги, не предусмотренные Спецификацией к настоящему Договору, и предоставлять Заказчику в письменной форме технико-экономическое предложение на оказание запрашиваемых услуг или обоснованный отказ в связи с невозможностью оказания запрашиваемых услуг.
В случае подачи Заказчиком запроса на вынос оборудования с Технологической площадки Исполнителя, передать по Акту приема-передачи оборудования, подписываемому уполномоченными представителями Сторон, принадлежащее Заказчику оборудование в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг.
По окончании монтажа оборудования Заказчика на Технологической площадке провести подключение оборудования к сети электропитания; принять оборудование Заказчика в эксплуатацию. По факту передачи оборудования Заказчика в эксплуатацию уполномоченные представители Сторон подписывают Акт начала оказания Услуг.
В случае предоставления Заказчиком Исполнителю учетной информации для получения доступа к услугам обеспечить конфиденциальность данной информации в соответствии со ст. 12 настоящего Договора.
В случае возникновения аварий и их последствий на Технологической площадке обе Стороны составляют Акт, устанавливающий причины, виновника возникновения аварий, размеры и порядок возмещения ущерба.
Обеспечивать представителям Заказчика возможность круглосуточного доступа к размещенному оборудованию Заказчика в соответствии с условиями настоящего Договора и Приложений к нему.
## Попов_0001 принимать и оплачивать Услуги Исполнителя в размере и в сроки, установленные настоящим Договором.
Предоставлять в согласованные сроки Исполнителю по его требованию все необходимые для оказания им Услуг материалы, документы и сведения технического и организационного характера.
За свой счет доставить оборудование Заказчика в предварительно согласованные Сторонами сроки на Технологическую площадку Исполнителя, передать Исполнителю оборудование в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг, по Акту приема-передачи оборудования, подписываемому уполномоченными представителями Сторон, и обеспечить необходимые условия проверки его комплектации и работоспособности Исполнителем.
Информировать Исполнителя о вносимых в документацию по обслуживанию оборудования дополнениях и изменениях не позднее 1 (одного) рабочего дня со дня внесения таких дополнений и изменений.
Соблюдать пропускной режим, правила посещения Технологической площадки, правила пожарной безопасности и техники безопасности, требования Госсанэпидемнадзора, а также отраслевых правил и норм, действующих в сфере деятельности Заказчика, в отношении Технологической площадки и предоставленного Исполнителем оборудования.
Немедленно извещать Исполнителя о всяком повреждении, аварии, угрозе повреждения или аварии, или ином событии, которые могут нанести ущерб находящемуся на Технологической площадке оборудованию, и своевременно принимать все возможные меры по предотвращению дальнейшего разрушения или повреждения оборудования, находящегося на Технологической площадке, а также самой Технологической площадки.
Не производить прокладок скрытых и открытых проводок и коммуникаций, перепланировок и переоборудования Технологической площадки, вызываемых потребностями Заказчика, без предварительного письменного разрешения Исполнителя и собственника здания. При наличии необходимости у Заказчика прокладки проводок и коммуникаций, перепланировок и переоборудования Технологической площадки и при наличии письменного разрешения Исполнителя и собственника здания, соответствующие работы выполняются за счет Заказчика. Затраты на проведение вышеуказанных работ не подлежат возмещению Исполнителем или собственником здания.
В случае обнаружения Исполнителем самовольных перестроек, нарушения целостности стен, перегородок или перекрытий, переделок или прокладок сетей, проводок и иных коммуникаций на Технологической площадке, таковые должны быть ликвидированы Заказчиком, а Технологическая площадка приведена в прежний вид за его счет в срок, определяемый односторонним предписанием Исполнителя.
Производить монтаж/демонтаж и подключение к сети или отключение от сети электропитания Технологической площадки Исполнителя любого оборудования Заказчика на Технологической площадке Исполнителя строго в соответствии с Регламентом взаимодействия Сторон в процессе оказания Услуг.
Не производить подключение любого оборудования к оборудованию Исполнителя и иному оборудованию, не принадлежащему Заказчику, без предварительного письменного разрешения Исполнителя.
В случае предоставления Исполнителем на основании отдельно заключаемого Сторонами соглашения принадлежащих Исполнителю оборудования, программного обеспечения и/или технических средств в пользование Заказчику использовать их исключительно в соответствии с условиями такого соглашения и только для получения Услуг Исполнителя.
После окончания срока, на который были предоставлены оборудование и/или технические средства, прекратить их использование и возвратить Исполнителю предоставленное им оборудование и/или технические средства в том состоянии, в котором Заказчик их получил с учетом нормального износа, а также осуществить иные действия, предусмотренные соглашением Сторон. После окончания срока, на который было предоставлено в пользование программное обеспечение Исполнителя, осуществить действия, предусмотренные соглашением Сторон, в отношении такого программного обеспечения.
В случае неисполнения или ненадлежащего исполнения Заказчиком обязанностей, предусмотренных настоящим Договором и Приложениями к нему, по вывозу оборудования Заказчика с Технологической площадки, компенсировать расходы Исполнителя, связанные с демонтажем и хранением оборудования.
Обеспечивать Исполнителю возможность доступа к размещенному оборудованию Заказчика в соответствии с Регламентом взаимодействия Сторон в процессе оказания Услуг.
В случае предоставления Исполнителем доступа к учетной информации обеспечить ее конфиденциальность в соответствии со ст. 12 настоящего Договора.
В случае осуществления монтажа оборудования на Технологической площадке Заказчиком, по окончании монтажных работ сдать Исполнителю оборудование в эксплуатацию в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг. По факту передачи оборудования Заказчика в эксплуатацию уполномоченные представители Сторон подписывают Акт начала оказания Услуг.
Осуществлять вынос (внос) с (на) Технологической площадки Исполнителя оборудования, принадлежащего Заказчику, в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг, по Акту приема-передачи оборудования, подписываемому уполномоченными представителями Сторон.
Оказывать содействие в работе представителей Исполнителя включая участие специалистов и ответственных лиц Заказчика и/или его представителей в переговорах с представителями Исполнителя, согласование, утверждение и приемку результатов Услуг.
Возместить Исполнителю все убытки, понесенные в результате использования услуг в целях, запрещенных настоящим Договором, включая перерасход энергоресурсов, износ оборудования и затраты на устранение иных последствий.
# СОСТАВ, ПОРЯДОК И СРОКИ ОКАЗАНИЯ УСЛУГ
Этапы оказания Услуг по Договору:
Первый этап – оказание Инсталляционных услуг. Продолжительность оказания Инсталляционных услуг устанавливается в Спецификации.
Второй этап - оказание Абонентских и Тарифицируемых услуг.
Дата начала оказания Инсталляционных услуг определяется в Спецификации, но не ранее получения всей информации и документов от Заказчика, необходимых Исполнителю для начала предоставления Инсталляционных услуг по настоящему Договору.
Исполнитель имеет право изменить (соразмерно продлить) срок предоставления Инсталляционных услуг в случае, если Заказчик не предоставил в согласованные сроки, указанные в настоящем Договоре документы и информацию. В этом случае Исполнитель направляет Заказчику письменное уведомление об изменении (соразмерном продлении) сроков предоставления Инсталляционных услуг.
Планируемая дата начала оказания Абонентских услуг по Договору определяется в Спецификации. Фактическая дата начала оказания Абонентских услуг по Договору определяется датой подписания Акта начала оказания Услуг обеими Сторонами (по форме Приложения №5 к настоящему Договору). Заказчик признает и соглашается, что в случае несвоевременного оказания Исполнителем Инсталляционных услуг, которое произошло в связи с неисполнением, ненадлежащим исполнением обязательств Заказчика по Договору или по инициативе Заказчика, датой начала оказания Исполнителем Абонентских и Тарифицируемых услуг по настоящему Договору является планируемая дата начала оказания соответствующих Услуг, указанная в Спецификации. В этом случае Исполнитель выставляет счета Заказчику, начисляет платежи за соответствующие Услуги, а Заказчик обязуется оплачивать стоимость Услуг, с планируемой даты начала оказания Услуг. Исполнитель признает и соглашается с тем, что в случае готовности Заказчика к началу оказания услуг ранее планируемой даты начала оказания Услуг, указанной в Спецификации, Стороны вправе подписать Акт начала оказания услуг. В этом случае начисление платежей по Договору производится с даты подписания данного Акта.
Если Заказчик планирует ввоз оборудования последовательно, то этап оказания Абонентских и Тарифицируемых услуг по Договору начинается с даты подписания первого Акта начала оказания услуг. Незаполненные оборудованием стойки оплачиваются Заказчиком, исходя из стоимости резерва места под стандартную стойку на территории Технологической площадки, указанной в Спецификации.
Перечень и объем Услуг указывается в Спецификации.
Схема размещения оборудования и технические требования указываются в Технических условиях (Приложение №4 к настоящему Договору).
# СТОИМОСТЬ УСЛУГ
Стоимость Инсталляционных, Абонентских (ежемесячных) и Тарифицируемых услуг указывается в Спецификации.
Стоимость Услуг рассчитывается в соответствии со Спецификацией и запросами на оказание Тарифицируемых Услуг, поступившими от Заказчика в порядке, предусмотренном Регламентом взаимодействия Сторон в процессе оказания Услуг (Приложение №3 к настоящему Договору).
Стоимость фактически оказанных Услуг рассчитывается Исполнителем исключительно на основании зафиксированных Исполнителем данных потребления Услуг, включая разовые (дополнительные) и Тарифицируемые услуги.
# ПОРЯДОК И СРОКИ ОПЛАТЫ
Все расчеты по настоящему Договору осуществляются в безналичной форме в рублях Российской Федерации. Датой оплаты считается дата зачисления денежных средств на расчетный счет Исполнителя. Факт оплаты Заказчик подтверждает копией платежного поручения с отметкой банка, высланной в адрес Исполнителя по факсу, электронным или иным способом.
Расчеты между Сторонами по платежам, обусловленным доплатой или возвратом денежных средств, производятся на основании двустороннего Акта сверки расчетов и счета, выставляемого в срок не позднее 5 (пяти) рабочих дней с момента подписания Сторонами Акта сверки расчетов.
Все платежи по настоящему Договору производятся Заказчиком в адрес Исполнителя по реквизитам, указанным в разделе 18 настоящего Договора, на основании выставляемых Исполнителем счетов не позднее 10 (десяти) дней с момента их выставления. Все расходы, связанные с переводом денежных средств на расчетный счет Исполнителя, принимает на себя Заказчик.
Счета на оплату направляются по электронной почте в адрес Заказчика. Доставка оригинала УПД производится путем отправки курьерской или почтовой корреспонденции.
Оплата за Инсталляционные услуги производится Заказчиком единовременно постоплатой в размере 100% (ста процентов) от стоимости этих Услуг на основании счета, выставляемого Исполнителем одновременно с УПД.
Оплата за Абонентские услуги производится Заказчиком ежемесячно постоплатой в размере 100% (ста процентов) от стоимости Абонентских услуг на основании счета, выставляемого Исполнителем одновременно с УПД.
Оплата Тарифицируемых услуг производится Заказчиком ежемесячно после окончания Отчетного периода на основании счета, выставляемого Исполнителем одновременно с УПД.
В случае оказания Исполнителем разовых (дополнительных) услуг Заказчику их оплата производится на основании отдельного счета, выставленного на основании УПД. В данном УПД Исполнитель указывает перечень оказанных Услуг. Услуги предоставляются на основании письменного запроса Заказчика, оформленного в соответствии Регламентом взаимодействия Сторон в процессе оказания Услуг.
В случае изменения стоимости Услуг согласно условиям настоящего Договора, ранее внесенная Заказчиком оплата за эти Услуги не пересчитывается по новой стоимости. В этом случае Услуги считаются оплаченными, и Исполнитель обязуется их предоставить в соответствии с условиями настоящего Договора.
Стоимость Услуг, оказанных Исполнителем за неполный Отчетный период, рассчитывается Исполнителем в соответствии со Спецификацией пропорционально времени оказания Услуг.
# ПОРЯДОК ВЗАИМОДЕЙСТВИЯ СТОРОН
Порядок взаимодействия Сторон в процессе оказания Услуг, порядок и формы обмена информацией и документами между Сторонами определяется Регламентом взаимодействия Сторон в процессе оказания Услуг (Приложение №3 к настоящему Договору) и условиями настоящего Договора.
# ПОРЯДОК СДАЧИ И ПРИЕМКИ УСЛУГ
Сдача и приемка Абонентских и Тарифицируемых услуг производится ежемесячно на основании УПД.
Сдача и приемка Инсталляционных услуг производится на основании УПД, подписываемого обеими Сторонами по окончании этапа инсталляции.
Сдача и приемка разовых (дополнительных) услуг производится на основании УПД, подписываемого обеими Сторонами по окончании этапа оказания этих Услуг.
По завершении каждого месяца оказания Услуг, по окончании этапа инсталляции, а также в иных случаях, предусмотренных Договором, в течение пяти рабочих дней Исполнитель направляет Заказчику для ознакомления и подписания два экземпляра, соответствующего УПД, датированные последним днем Этапа оказания Услуг.
Заказчик в течение 5 (пяти) рабочих дней со дня выставления УПД обязан направить Исполнителю один экземпляр подписанного им УПД или письменный мотивированный отказ от приемки данного Этапа Услуг.
В случае предъявления Заказчиком мотивированного отказа от приемки Услуг, Сторонами в течение 10 (десяти) календарных дней составляется двусторонний протокол с перечнем необходимых доработок и сроков их выполнения. Указанные доработки производятся силами и за счет Исполнителя. Повторная сдача-приемка Услуг производится Заказчиком в порядке, установленном настоящим Договором.
В случае неисполнения Заказчиком условий, указанных в п.7.5 настоящего Договора, Услуги считаются оказанными в полном объеме и с надлежащим качеством, принятыми Заказчиком без претензий и подлежащими оплате, а УПД им подписанным.
# ОСОБЫЕ УСЛОВИЯ
## Исполнитель вправе приостановить оказание Услуг в следующих случаях:
Просрочки Заказчиком платежей за оказанные Услуги на срок свыше десяти рабочих дней с момента выставления счета и УПД или в случае поступления оплаты не в полном объеме - до момента полной оплаты;
При обнаружении неисправности, несоответствия оборудования Заказчика заявленным параметрам или при возникновении форс-мажорных обстоятельств, препятствующих качественному обслуживанию оборудования Заказчика до момента устранения соответствующих обстоятельств;
При возникновении форс-мажорных обстоятельств, препятствующих качественному предоставлению Услуг Заказчику до момента устранения соответствующих обстоятельств;
Если использование Заказчиком Услуг может нанести ущерб Исполнителю и/или вызвать сбой технических и программных средств Исполнителя и третьих лиц;
Заказчик использует Услуги Исполнителя, либо Заказчик использует Технологическую площадку Исполнителя для каких-либо незаконных целей, в том числе, но не ограничиваясь этим, нарушает права на интеллектуальную собственность, исключительные права, патентные, авторские, смежные или иные охраняемые законом права Исполнителя и третьих лиц;
Заказчик эксплуатирует предоставленное оборудование с нарушением правил технической эксплуатации;
Наличия со стороны Заказчика любых противоправных действий, включая, но не ограничиваясь этим, получение, копирование, использование, отправление, опубликование, передача, воспроизведение, распространение любой охраняемой законодательством РФ информации, данных и сведений любым способом, а также любые действия, в том числе использование, копирование и распространение, в отношении программного обеспечения и/или других охраняемых объектов интеллектуальной собственности, материалов, информации, сведений, в том числе полученных посредством Услуг, если Заказчиком не было получено предварительное письменное согласие правообладателя на указанные действия, и которые нарушают авторские, исключительные или любые другие права правообладателя;
Распространения Заказчиком и/или публикации любой информации, которая противоречит требованиям действующего законодательства Российской Федерации, положениям лицензий Министерства связи РФ (Министерства информационных технологий и связи в РФ), нормам международного права, и ущемляет права третьих лиц;
Опубликования, копирования или распространения Заказчиком любой информации или программного обеспечения, которое содержит в себе коды компьютерных вирусов или других компонентов, приравненных к ним;
Если Исполнитель получает соответствующее законное указание о прекращении оказания Услуг со стороны какого-либо государственного регулирующего или иного компетентного органа до момента отмены указания;
Если Исполнитель получает указание со стороны ответственного представителя Заказчика о приостановлении оказания Услуг. Услуги приостанавливаются до момента, обозначенного в указании, либо до его отмены ответственным представителем Заказчика.
## В случае возникновения событий, указанных в п.8.1.1, 8.1.2, 8.1.4, 8.1.11 настоящего Договора, Исполнитель письменно уведомляет Заказчика не позднее, чем за 5 (пять) Рабочих дней до приостановления Услуг с обоснованием причин приостановления оказания Услуг;
В случае возникновения событий, указанных в п. 8.1.2., 8.1.5, 8.1.7, 8.1.8 настоящего Договора, Исполнитель одновременно с приостановкой оказания Услуг Заказчику письменно уведомляет Заказчика о необходимости устранения соответствующих нарушений. Заказчик обязуется в течение 24 часов после получения письменного уведомления Исполнителя устранить эти нарушения, если иной срок не указан в приложениях к настоящему договору. Исполнитель возобновляет оказание Услуг Заказчику только после устранения Заказчиком нарушений.
В случае возникновения событий, указанных в п.п.8.1.10-8.1.11 настоящего Договора, Исполнитель в течение 5 (пяти) рабочих дней с даты приостановления оказания Услуг письменно уведомляет об этом Заказчика с обоснованием причин приостановления оказания Услуг и приложением подтверждающих это документов.
## Время приостановления оказания Услуг по основаниям, указанным в п.8.1 Договора, не считается перерывом в оказании Услуг и не может рассматриваться как нарушение Исполнителем своих обязательств, предусмотренных настоящим Договором и Приложениями к нему.
## Приостановление оказания Услуг и/или отключение программных или аппаратных средств Заказчика осуществляется до выполнения Заказчиком требований Исполнителя по устранению нарушений, указанных в п. 8.1. Приостановление оказания Услуг не отменяет выполнения Заказчиком всех своих обязательств по настоящему Договору, включая обязательство по ежемесячным платежам.
## Заказчик обязуется компенсировать все документально обоснованные расходы и затраты, понесенные Исполнителем в результате приостановления и возобновления оказания Услуг, указанных в п.8.1.
8.6. В случае приостановления оказания услуг или расторжения договора наличие и хранение резервных копии информационной системы (базы данных, программного обеспечения и пр. элементов) осуществляется силами и за счет Заказчика. Заказчик самостоятельно определяет перечень, обеспечивает сбор и перенос файлов для резервного хранения в течение 10 (десяти) календарных дней с момента получения уведомления о приостановлении услуг или расторжения договора.
# ОТВЕТСТВЕННОСТЬ СТОРОН
## Общие положения
За невыполнение или ненадлежащее выполнение своих обязательств по настоящему Договору Стороны несут ответственность в соответствии с действующим законодательством РФ, настоящим Договором и Приложениями к настоящему Договору.
Каждая из Сторон является единственно ответственной за соблюдение лицензионного законодательства в отношении используемых ее сотрудниками аппаратных и программных средств.
Ни одна из Сторон ни при каких обстоятельствах не несет ответственности перед другой Стороной за «косвенные» убытки (упущенную выгоду), которые включают, но не ограничиваются, потерю дохода, прибыли, ожидаемой экономии, деловой активности или репутации.
Заказчик и Исполнитель отвечают друг перед другом за правильность, достоверность и полноту передаваемых ими друг другу технических требований, документов, сведений и иной информации. В случае использования одной из Сторон недостоверной или неполной информации, полученной от другой Стороны, Сторона, получившая информацию, не несет ответственности за последствия, вызванные ее действиями на основании предоставленной недостоверной информации.
В случае просрочки в оплате Услуг Исполнитель имеет право потребовать от Заказчика уплаты пени в размере 0,1% (ноль целых одной десятой) процента от неоплаченной суммы за каждый день просрочки, но не более 10% (десяти) процентов от просроченного платежа.
В случае просрочки оказания любых Услуг, предусмотренных настоящим Договором, Заказчик имеет право потребовать от Исполнителя уплаты пени в размере 0,1% (ноль целых одной десятой) процента от не выполненных в срок Услуг за каждый день просрочки, но не более 10% (десяти) процентов от не выполненных в срок Услуг.
Максимальный размер ответственности Исполнителя за ненадлежащее оказание Услуг по настоящему Договору устанавливается в размере суммы, фактически уплаченной Заказчиком за оказание соответствующих Услуг по Договору в течение Отчетного периода, в котором данное ненадлежащее исполнение допущено. Конкретная сумма, подлежащая уплате Заказчику, в случае нарушения условий предоставления Услуг Исполнителем определяется согласно схеме компенсации, указанной в пункте 2.3 Соглашения об уровне обслуживания (Приложение №2 к настоящему Договору).
Заказчик имеет право на компенсацию в размере и порядке, установленном в пункте 2.3 Соглашения об уровне обслуживания.
Уплата штрафных санкций не освобождает Стороны от выполнения обязательств по настоящему Договору.
Каждая из Сторон Договора обязуется в течение срока действия настоящего Договора, а также в течение года с момента его расторжения/прекращения, воздерживаться от осуществления действий, направленных на переманивание работников другой Стороны.
## Семёнов_0001 несет ответственность перед третьими лицами в случае предъявления любых претензий и исков такими третьими лицами, заключившими договоры с Заказчиком на оказание услуг, которые частично или полностью оказываются Заказчиком с помощью Услуг Исполнителя.
Заказчик несет ответственность за соблюдение прав Исполнителя и третьих лиц на интеллектуальную собственность, исключительных прав, патентных, авторских, смежных или иных охраняемых законом прав. В случае, если к Исполнителю по вине Заказчика будут предъявлены со стороны третьих лиц какие-либо претензии, вытекающие из нарушения Заказчиком прав на интеллектуальную собственность, исключительных прав, патентных, авторских, смежных или иных охраняемых законом прав, Заказчик обязуется принять на себя эти претензии и возместить Исполнителю все убытки и расходы, понесенные Исполнителем в связи с нарушением таких прав, и за свой счет и на свой риск незамедлительно принять меры к урегулированию заявленных претензий.
Заказчик полностью ответственен за сохранность своих учетных данных и за убытки или иной ущерб, которые могут возникнуть по причине несанкционированного использования этой информации. По факту утери, несанкционированного доступа к учетным данным или возможности возникновения такой ситуации Заказчик имеет право направить запрос Исполнителю на смену учетных данных. В этом случае Исполнитель по согласованию с Заказчиком осуществляет блокировку доступа к учетным данным Заказчика. Срок подобной блокировки согласовывается Сторонами дополнительно. При этом Исполнитель не несет ответственности за извещение любых третьих лиц о временной приостановке доступа Заказчика к его информационной системе и за возможные последствия, возникшие в результате отсутствия такого извещения.
Заказчик несет ответственность за поддержание в актуальном состоянии списка его представителей, имеющих право обращаться в службу поддержки Исполнителя с запросами на изменение параметров Услуг.
Заказчик несет ответственность за убытки, причиненные Исполнителю в результате повреждения и/или утраты оборудования Исполнителя и третьих лиц.
Заказчик несет ответственность за поддержание в актуальном состоянии списка его представителей, допущенных на Технологическую площадку и к размещенному на ней оборудованию Заказчика.
Заказчик несет ответственность за соответствие параметров своего размещаемого оборудования эксплуатационным требованиям и нормам, принятым на Технологической площадке Исполнителя. Любые изменения к требованиям и нормам на Технологической площадке Исполнителя должны быть сообщены Заказчику письменно не позднее, чем за тридцать календарных дней до их введения в действие.
Заказчик обязуется возместить Исполнителю убытки, причиненные свойствами переданного оборудования, если Исполнитель, размещая оборудование Заказчика, не был письменно проинформирован об этих свойствах.
На основании дополнительного письменного запроса Исполнитель предоставляет сотрудникам Заказчика, чьи фамилия, имя, отчество, должность и иные сведения, предоставленные Исполнителю по его требованию, указаны Заказчиком в запросе, допуск для обеспечения круглосуточного контролируемого физического доступа на Технологическую площадку в соответствии с Регламентом взаимодействия Сторон в процессе оказания услуг (Приложение №3 к настоящему Договору). Заказчик несет ответственность за безопасность и надлежащее использование пропусков на проход представителей Заказчика на территорию Технологической площадки. Пропуск не может быть передан третьим лицам. Исполнитель не несет ответственность за убытки, понесенные Заказчиком в результате потери пропусков и разглашения иных сведений по вине Заказчика.
Заказчик несет ответственность за убытки, причиненные Исполнителю в результате повреждения и/или утраты оборудования Исполнителя и третьих лиц, разместивших свое оборудование на Технологической площадке, которые произошли в результате действий или бездействий представителей Заказчика и/или привлекаемой Заказчиком третьей стороны).
Заказчик гарантирует, что не является участником деятельности, связанной с криптовалютным майнингом (включая, но не ограничиваясь, майнингом биткойнов, эфириума и иных криптовалют), а также не планирует использовать предоставляемые Исполнителем услуги, оборудование, вычислительные мощности, энергоресурсы или инфраструктуру центра обработки данных для осуществления майнинга или иных операций, связанных с генерацией криптовалют.
Заказчик берет на себя обязательство:
До заключения настоящего Договора письменно уведомить Исполнителя о любых обстоятельствах, прямо или косвенно связанных с криптовалютным майнингом, если такие обстоятельства могут повлиять на исполнение обязательств по Договору.
Незамедлительно информировать Исполнителя о возникновении в течение срока действия Договора намерения использовать услуги в целях, указанных в п. 9.2.11 настоящего Договора.
## Кузнецов_0001 не контролирует содержание информации, хранимой, публикуемой или распространяемой Заказчиком с использованием предоставленных Услуг, и не несет никакой ответственности за точность, качество и содержание такой информации.
Исполнитель не несет ответственность за нарушение Заказчиком прав третьих лиц, включая, но не ограничиваясь этим, нарушение Заказчиком прав на интеллектуальную собственность, исключительных прав, патентных, авторских, смежных или иных охраняемых законом прав, в том числе за нарушение, совершенное с использованием Услуг, предоставляемых Исполнителем.
Исполнитель не несет ответственность за сбои в телекоммуникационных сетях, не находящихся под контролем Исполнителя в соответствии с Соглашением об уровне обслуживания.
Исполнитель не несет ответственность за последствия изменений в размещаемом оборудовании и установленном на нем программном обеспечении, не согласованных с Исполнителем, и не гарантирует работоспособность ПАК Заказчика в случае внесения таких изменений Заказчиком.
Исполнитель не является ответчиком или соответчиком по любым обязательствам и расходам, связанным с нарушением положений настоящего Договора Заказчиком или третьими лицами, использующими учетные данные Заказчика, а также связанным с использованием Заказчиком Интернет посредством Услуг Исполнителя; или связанным с помещением или передачей любого сообщения, информации, программного обеспечения или других материалов в сети Интернет Заказчиком или другими лицами, использующими его учетные данные.
Исполнитель несет ответственность перед Заказчиком в размере, оговоренном в п.2.3. Соглашения об уровне обслуживания.
Исполнитель несет ответственность перед Заказчиком в размере документально подтвержденного ущерба, причиненного Заказчику в результате поломки, утраты или повреждения размещаемого оборудования Заказчика, принятого в эксплуатацию по условиям настоящего Договора, в соответствии с п.9.4 настоящего Договора, а также невозможности работы оборудования ПАК Заказчика по вине Исполнителя в результате:
Нарушения режимов электропитания;
Нарушения допустимых параметров в помещении;
Повреждений, нанесенных персоналом Исполнителя или представителями третьей стороны, привлекаемой Исполнителем для оказания Услуг по настоящему Договору в результате халатных и/или умышленных действий;
Повреждений, нанесенных иными лицами, не являющимися представителями Заказчика и/или привлекаемой Заказчиком третьей стороны, допущенными на Технологическую площадку с получением доступа от Исполнителя, или проникших на Технологическую площадку в результате халатности Исполнителя и/или несоблюдении им Регламента взаимодействия Сторон в процессе оказания Услуг (Приложение №3 к настоящему Договору).
Исполнитель не несет ответственность за работоспособность оборудования Заказчика, не принятого в эксплуатацию в соответствии с условиями настоящего Договора.
Исполнитель не несет ответственность за утрату или повреждение оборудования Заказчика, в случаях, когда утрата или повреждение произошли из-за свойств оборудования Заказчика, о которых Исполнитель, размещая его на Технологической площадке, не был письменно (под роспись) предупрежден Заказчиком.
Исполнитель несет ответственность в соответствии с п.9.4 настоящего Договора за повреждение оборудования Заказчика, произошедшее как в результате халатных и/или умышленных действий сотрудников Исполнителя и/или уполномоченных им третьих лиц, так и в результате нарушения Исполнителем технических условий и правил технической эксплуатации данного оборудования.
## Ответственность сторон при утрате и повреждении оборудования
Исполнитель несет ответственность при:
утрате оборудования Заказчика в размере его рублевой балансовой стоимости, приведенной в Акте приема-передачи оборудования (Приложение №5 к настоящему Договору), с учетом норм амортизации (износа), зафиксированных в Акте убытия ОС;
повреждении оборудования – в размере документально подтвержденной стоимости восстановительного ремонта поврежденного оборудования, включая заменяемые в процессе ремонта поврежденные составляющие оборудования до состояния, соответствующего первоначальному. Стоимость подлежащих замене составляющих определяется на основании балансовой стоимости таких составляющих, указанных в Акте приема-передачи оборудования (Приложение №5 к настоящему Договору).
Любая из Сторон в случае утраты или повреждения оборудования обязана немедленно (в течение суток с момента наступления такого случая) письменно известить об этом другую Сторону.
В случае отсутствия спора Сторон о причинах утраты оборудования Исполнитель обязан в течение 15 (пятнадцати) рабочих дней с момента получения требования от Заказчика перечислить Заказчику рублевую балансовую стоимость оборудования, которая указана в Акте приема-передачи оборудования, с учетом норм амортизации (износа), зафиксированных в Акте убытия ОС, на счет Заказчика.
В случае отсутствия спора Сторон о причинах повреждения оборудования Заказчика и признания Исполнителем своей ответственности за повреждение оборудования Заказчика сумма восстановительного ремонта определяется путем переговоров между Исполнителем и Заказчиком, результат которых оформляется в виде Акта о повреждении (утрате) оборудования Заказчика (форма акта в Приложении №5 к настоящему Договору), в котором фиксируется факт повреждения оборудования, время и причины повреждения оборудования, а также согласованная Сторонами стоимость его восстановительного ремонта. Исполнитель обязан в течение пяти рабочих дней с даты подписания обеими Сторонами Акта о повреждении (утрате) оборудования Заказчика перечислить Заказчику рублевую стоимость восстановительного ремонта оборудования, указанную в данном Акте, на счет Заказчика. В случае спора Сторон о стоимости восстановительного ремонта и отказа одной из Сторон от подписания Акта о повреждении (утрате) оборудования Стороны решают спор в порядке, предусмотренном условиями пп.9.4.5-9.4.7 настоящего Договора.
В случае спора Сторон о причинах утраты или повреждения оборудования, а также о стоимости восстановительного ремонта, Заказчик имеет право проведения полной технической экспертизы технического состояния поврежденного оборудования. При этом техническая экспертиза должна быть проведена либо изготовителем данного оборудования, либо иной организацией, уполномоченной на осуществление данных действий изготовителем поврежденного оборудования, с участием привлеченной Исполнителем организации, компетентной и уполномоченной на проведение технической экспертизы такого оборудования. Этим же Актом технической экспертизы технического состояния поврежденного оборудования должна быть установлена стоимость его восстановительного ремонта, если таковой может быть произведен, и рублевая балансовая стоимость, приведенная в Акте приема-передачи оборудования, с учетом норм амортизации (износа), зафиксированных в Акте убытия ОС.
В случае отказа Исполнителя от проведения технической экспертизы в течение 10 (десяти) рабочих дней с момента повреждения оборудования, Заказчик имеет право провести техническую экспертизу в одностороннем порядке. В данном случае Исполнитель обязан не препятствовать доступу специалистов, осуществляющих техническую экспертизу, к поврежденному оборудованию.
Если по результатам технической экспертизы виновным за утрату или повреждение оборудования Заказчика будет признан Исполнитель, Заказчик обязан в течение 5 (пяти) рабочих дней с даты получения Акта технической экспертизы передать один экземпляр оригинала данного Акта Исполнителю. В случае согласия Исполнителя с результатами технической экспертизы он обязан в течение 5 (пяти) рабочих дней с даты получения им оригинала Акта технической экспертизы перечислить Заказчику рублевую стоимость:
восстановительного ремонта оборудования, указанную в Акте технической экспертизы, в случае если такой ремонт может быть произведен, либо
рублевую балансовую стоимость поврежденного оборудования, указанную в Акте приема-передачи оборудования, с учетом норм амортизации (износа), зафиксированных в Акте убытия ОС, в случае, если проведение восстановительного ремонта не представляется возможным, на счет Заказчика в соответствии с реквизитами, указанными в разделе 18 настоящего Договора. При этом Исполнитель обязан компенсировать Заказчику все документально подтвержденные затраты на проведение технической экспертизы.
В случае несогласия Исполнителя с результатами технической экспертизы он обязан в течение пяти рабочих дней с даты получения Акта технической экспертизы письменно известить об этом Заказчика, давая основание Заказчику перенести данный спор на рассмотрение в суд.
При судебном разбирательстве Заказчик вправе потребовать от Исполнителя не только стоимость поврежденного оборудования, но и компенсацию документально подтвержденных расходов по проведению технической экспертизы.
Заказчик несет ответственность за нарушение работоспособности, утрату или повреждение оборудования, размещенного на Технологической площадке Исполнителя и не принадлежащего Заказчику, нанесенных представителями Заказчика и/или, привлекаемой Заказчиком третьей стороной, допущенными на Технологическую площадку с получением доступа от Исполнителя по запросу Заказчика в соответствии с Регламентом взаимодействия Сторон в процессе оказания Услуг.
За утрату или частичное повреждение размещаемого на Технологической площадке оборудования ПАК Заказчика после того, как наступила обязанность Заказчика вывезти оборудование, Исполнитель несет ответственность лишь при наличии с его стороны умысла или грубой неосторожности.
Ответственность за порчу или утрату предоставленных Исполнителем в пользование Заказчику оборудования и технических средств несет Заказчик, кроме случаев, если будет доказана вина Исполнителя или привлекаемой им в процессе оказания Услуг третьей стороны. В случае возникновения спора между Заказчиком и Исполнителем о причинах утраты, повреждения оборудования и технических средств, а также стоимости восстановительного ремонта, Стороны вправе провести техническую экспертизу в порядке, предусмотренном пп. 9.4.5-9.4.7 настоящего Договора. Инициатором проведения экспертизы является Исполнитель. В случае признания вины Заказчика в повреждении или утрате оборудования или технических средств, Заказчик обязуется компенсировать Исполнителю документально подтвержденные затраты на проведение технической экспертизы в течение 5 (пяти) календарных дней с момента предоставления Исполнителем копий документов, подтверждающих понесенные на экспертизу затраты.
В случае несогласия Заказчика с результатами технической экспертизы он обязан в течение пяти рабочих дней с даты получения Акта технической экспертизы письменно известить об этом Исполнителя, давая основание Исполнителю перенести данный спор на рассмотрение в суд.
# ФОРС-МАЖОРНЫЕ ОБСТОЯТЕЛЬСТВА
В случае возникновения форс-мажорных обстоятельств: стихийных бедствий, землетрясений, наводнений, пожаров, и других обстоятельств непреодолимой силы, Исполнитель и Заказчик освобождаются от ответственности за неисполнение или ненадлежащее исполнение обязательств по настоящему Договору. В течение трех календарных дней с момента наступления таких обстоятельств Сторона, пострадавшая от их влияния, письменно уведомляет другую Сторону о произошедших событиях.
Сторона, понесшая в связи с обстоятельствами непреодолимой силы убытки из-за неисполнения или приостановления другой Стороной исполнения своих обязательств, может потребовать от Стороны, ставшей объектом действия непреодолимой силы, документ, подтверждающий масштабы произошедших событий, а также об их влиянии на деятельность пострадавшей Стороны.
В случае возникновения форс-мажорных обстоятельств исполнение обязательств по настоящему Договору отодвигается на срок действия таких обстоятельств.
В случае возникновения обстоятельств непреодолимой силы, продолжающихся более трех месяцев, Заказчик или Исполнитель вправе расторгнуть настоящий Договор после взаиморасчетов по фактически выполненным обязательствам.
# РАЗРЕШЕНИЕ СПОРОВ
Споры и разногласия, возникающие по настоящему Договору или в связи с ним, решаются Сторонами прежде всего путем переговоров или заключением Дополнительных соглашений. Срок ответа на претензию – 10 (десять) рабочих дней с момента получения.
Все споры, разногласия или требования, возникающие из настоящего Договора или в связи с ним, в том числе касающиеся его исполнения, нарушения, прекращения или недействительности, подлежат разрешению в Арбитражном суде г. Москвы в соответствии с законодательством РФ.
# КОНФИДЕНЦИАЛЬНОСТЬ
Каждая из Сторон сохраняет в тайне всю Конфиденциальную информацию, раскрытую ей другой Стороной. Эта конфиденциальность распространяется, в том числе на любую конфиденциальную информацию, полученную Сторонами друг от друга устно, в письменном, электронном виде, а также в любой иной форме, в процессе оказания Услуг по настоящему Договору, и содержащую сведения об организационной структуре, документах и материалах по исполнению настоящего Договора, коммерческих аспектах настоящего Договора, о рыночных и технических планах развития и другие сведения, являющиеся исключительно Конфиденциальной информацией Сторон.
Стороны будут ответственны за несанкционированное разглашение и использование Конфиденциальной информации. Каждая Сторона обязуется соблюдать в отношении Конфиденциальной информации столь же высокую степень осторожности, какую она соблюдает в отношении своей собственной Конфиденциальной информации аналогичной степени важности.
Нарушением данного пункта не являются следующие случаи:
разглашение информации, на которое получено письменное разрешение другой Стороны;
разглашение информации по законному требованию компетентных государственных органов в соответствии с действующим законодательством РФ либо по решению компетентного суда;
разглашение информации регулирующему органу, эксперту или арбитру, назначенному в соответствии с положениями настоящего Договора, в той степени, в которой такое разглашение обязательно по закону;
разглашение информации финансовым или профессиональным советникам разглашающей Стороны при условии, что они берут на себя письменное обязательство по сохранению конфиденциальности данной информации.
Стороны обязуются использовать предоставленную друг другу Конфиденциальную информацию исключительно в интересах выполнения обязательств по настоящему Договору, а не во вред другой Стороне.
Конфиденциальность информации сохраняется обеими Сторонами после окончания срока действия или расторжения настоящего Договора в течение двадцати четырех календарных месяцев.
# ПРАВА НА ИНТЕЛЛЕКТУАЛЬНУЮ СОБСТВЕННОСТЬ
Ничто в настоящем Договоре не может быть интерпретировано как предоставление каких-либо прав на интеллектуальную собственность или как уступку прав на интеллектуальную собственность одной Стороны другой Стороне.
Каждая Сторона самостоятельно несет ответственность за любые убытки, ущерб, или расходы, понесенные ею в связи с нарушением прав третьих лиц на интеллектуальную собственность в ходе исполнения настоящего Договора.
# ПРОЧИЕ УСЛОВИЯ
Подписывая настоящий Договор, Заказчик подтверждает, что у него есть доступ в интернет и возможность отправлять и принимать электронную почту.
Передача любой из Сторон прав и обязанностей по настоящему Договору возможна только с предварительного письменного согласия другой Стороны, полученного не менее, чем за 30 (тридцать) календарных дней до даты передачи.
Исполнитель имеет право соответствующим образом изменить стоимость Услуг по настоящему Договору, в случае если будут приняты и вступят в силу изменения в законодательстве Российской Федерации: а) устанавливающие дополнительные налоги, сборы и прочие обязательные платежи/отчисления в отношении Услуг, оказываемых Исполнителем по настоящему Договору; б) устанавливающие обязанность Исполнителя по приобретению дополнительного оборудования, лицензий, разрешений, сертификатов, необходимых для оказания Услуг по настоящему Договору.
В случае если после заключения Договора будет официально опубликован (введен в действие) нормативный правовой акт, изменяющий ставку НДС и/или вводящий иные налоги и сборы, влияющие на стоимость Услуг, цена Договора подлежит пересчету с учетом вступивших в силу изменений.
Исполнитель, руководствуясь ст. 431.2 ГК РФ, заверяет Заказчика на момент подписания настоящего Договора и гарантирует в налоговых периодах, в течении которых совершаются операции по настоящему Договору, что уплачивает все налоги и сборы в соответствии с действующим законодательством, а также ведет и своевременно подает в налоговые и иные государственные органы налоговую, статистическую и иную отчетность.
Исполнитель обязуется по запросу Заказчика, составленному на основании требования / запроса налогового или иного государственного органа (с приложением копии такого требования/запроса или выдержки из такого требования/запроса), предоставить  надлежащим образом заверенные копии документов, относящихся к исполнению настоящего Договора, в срок, не превышающий 5 (пять) рабочих дней с момента получения соответствующего запроса от Заказчика, либо в течение иного согласованного Заказчиком и Исполнителем срока.
# СРОК ДЕЙСТВИЯ, ПОРЯДОК ИЗМЕНЕНИЯ И РАСТОРЖЕНИЯ ДОГОВОРА
Договор вступает в силу с момента его подписания Сторонами. Начальный период действия настоящего Договора составляет 12 (двенадцать) календарных месяцев с даты его подписания Сторонами.
Каждая из Сторон может прекратить действие настоящего Договора по истечении установленного Начального периода, представив об этом письменное уведомление не позднее, чем за 30 (тридцать) календарных дней до даты прекращения срока действия настоящего Договора. При отсутствии такового уведомления срок действия Договора ежегодно автоматически продлевается на следующие 12 (двенадцать) календарных месяцев. При этом действие Договора может быть прекращено по истечении каждого последующего годового периода письменным уведомлением не позднее, чем за 30 (тридцать) календарных дней до даты завершения годового периода.
Любая из Сторон имеет право в одностороннем внесудебном порядке досрочно расторгнуть Договор, но не ранее, чем спустя тридцать календарных дней после письменного уведомления другой Стороны о своих намерениях. С момента получения письменного уведомления другой Стороной и до момента расторжения Договора никакие изменения, уменьшающие или увеличивающие совокупную величину ежемесячных платежей, в Договор внесены быть не могут.
Если одна из Сторон допускает существенное нарушение настоящего Договора, которое не устраняется в течение 30 (тридцати) календарных дней после письменного требования об этом другой Стороны, то другая Сторона может немедленно расторгнуть Договор в одностороннем внесудебном порядке, направив нарушившей Стороне соответствующее письменное уведомление.
Исполнитель имеет право в одностороннем внесудебном порядке расторгнуть или изменить Договор, не неся за это никакой ответственности, если, по не зависящим от него причинам, Исполнитель не получит какой-либо лицензии или разрешения, которые необходимы ему для оказания Услуг. Или, если они будут отозваны или иным образом отменены/изменены так, что это повлияет на способность Исполнителя оказывать Услуги.
В случае просрочки платежей Заказчиком более чем на 30 (тридцать) рабочих дней, Исполнитель вправе в одностороннем внесудебном порядке расторгнуть Договор с направлением Заказчику письменного уведомления о расторжении не менее, чем за 10 (десять) рабочих дней до даты расторжения. Договор считается расторгнутым с даты, указанной в уведомлении о расторжении.
Любые положения настоящего Договора могут быть изменены, отменены или прекращены по инициативе любой из Сторон. Изменения, отмена или прекращение действия положений Договора должны быть оформлены в письменном виде как Дополнительное соглашение к Договору, подписаны обеими Сторонами, и являются неотъемлемой частью настоящего Договора.
Изменение требований к количеству и качеству предоставляемых Услуг, критериев и порядка оценки предоставляемых Услуг в обязательном порядке оформляются изменением существующих или разработкой новых Спецификаций.
Изменение действующих Спецификаций или разработка новых происходит путем подписания Сторонами Дополнительных соглашений к Договору, после чего действие старых Спецификаций прекращается, и может быть инициирована любой из Сторон. Составление и оформление Спецификаций осуществляет Исполнитель.
По окончании срока действия настоящего Договора отключение оборудования Заказчика от сетевой инфраструктуры Исполнителя и/или вывоз его с Технологической площадки осуществляется Сторонами в следующем порядке:
Исполнитель не позднее двух календарных дней производит отключение оборудования Заказчика от сетевой инфраструктуры Исполнителя;
демонтаж и вывоз оборудования Заказчика с Технологической площадки Исполнителя осуществляется Заказчиком самостоятельно в течение десяти календарных дней с даты окончания срока действия Договора, либо в иной согласованный Сторонами срок, оформленный в виде Дополнительного соглашения;
отключение от электропитания и демонтаж стойки Заказчика по окончании срока действия настоящего Договора осуществляет Исполнитель, стоимость соответствующих услуг Исполнителя определяется, исходя из стоимости работ по деинсталляции инфраструктуры стойко-места, указанной в Спецификации. Заказчик обязуется подписать Дополнительное Соглашение на эти услуги и оплатить такие услуги Исполнителя в течение 5 (пять) рабочих дней с даты подписания Сторонами УПД и получения счета Исполнителя, но не позднее срока действия Договора;
если Заказчик не оплатил стоимость Услуг Исполнителя по отключению от электропитания и демонтажу оборудования, или по истечении срока размещения оборудование Заказчика не было принято Заказчиком и не было вывезено Заказчиком в согласованный Сторонами срок, Исполнитель за свой счет складирует данное оборудование на условиях ответственного хранения на своей территории. В этом случае оборудование передается Заказчику только после оплаты Заказчиком расходов Исполнителя по отключению, демонтажу, складированию, хранению и приему-передаче оборудования. При этом стоимость Услуг Исполнителя по хранению оборудования определяется Дополнительном соглашении, исходя из расчета стоимости аренды склада на Технологической площадке Исполнителя. Заказчик обязуется оплатить такие услуги Исполнителя до момента передачи оборудования Заказчику. В момент возврата оборудования Кузнецов_0002 подписывается Акт приема-передачи оборудования с хранения в двух экземплярах.
Никакое изменение, исправление, добавление или отмена любых положений настоящего Договора не вступает в силу, пока не будет согласовано в письменной форме уполномоченными представителями Исполнителя и Заказчика.
Исполнитель вправе расторгнуть настоящий Договор в одностороннем порядке в случае отказа или уклонения Заказчика от подписания дополнительного соглашения об изменении цен по Договору (п.2.1.3) на условиях Исполнителя, направив Заказчику письменное уведомление не менее, чем за 10 (десять) рабочих дней до даты предполагаемого расторжения. В этом случае оплата оказанных Исполнителем услуг до даты расторжения осуществляется Заказчиком на прежних условиях.
# ЗАКЛЮЧЕНИЕ ДОГОВОРА
Договор и все Приложения, являющиеся его неотъемлемой частью, представляют собой полный Договор между Сторонами относительно изложенного в нем содержания.
Договор и все Приложения к нему полностью исчерпывают договоренность между Сторонами по вопросу оказания Услуг и отменяют все другие соглашения и заявления, сделанные в устной или письменной форме до момента подписания настоящего Договора.
Каждая Сторона признает, что ознакомилась с настоящим Договором, и что она надлежащим образом уполномочена подписать Договор, и согласна с его сроками и условиями.
Стороны договорились, что переписка по электронной почте между уполномоченными лицами, указанными в Приложении № 3 к Договору, может рассматриваться ими в качестве доказательства, в случае возникновения спора.
Документы, переданные по электронной почте, имеют юридическую силу оригинала и действуют до момента обмена Сторонами оригиналами документов.
В целях оптимизации документооборота между Сторонами, а также повышения уровня сохранения и защиты передаваемых документов и информации, содержащейся в них, Стороны договорились о внедрении с даты подписания Договора системы электронного документооборота с использованием электронной подписи и передачей документов и информации через оператора электронного документа оборота (далее - «Оператор»).
Стороны договорились осуществлять электронный документооборот следующими документами:
# Счетами на оплату;
# Актами сверки взаимных расчетов;
# Протоколами разногласий к акту сверки взаимных расчетов;
# Актами зачета взаимных требований;
# Заявлениями о проведении зачета;
# Электронными счетами фактурами (формат УПД(СЧФ);
# Универсальными передаточными документами (формат УПД(СЧФДОП);
# Актами об оказании услуг (акты оказанных услуг);
# Актами выполненных работ (акты о передаче результатов работ);
# Товарными накладными по форме ТОРГ-12;
# Товарными накладными;
# Приложениями к Договору;
Документы считаются надлежаще полученными другой Стороной в случае:
направления документов на бумажном носителе – в момент фактической доставки (выдачи) корреспонденции представителю получателя или проставления штемпеля почтового отделения получателя о прибытии корреспонденции в его адрес, если обязанности по получению им корреспонденции не были исполнены;
направления документов по электронной почте уполномоченным лицам, указанным в Приложении № 3 к Договору – в рабочий день отправления сообщения с приложением документов, содержащих все надлежащие реквизиты, позволяющие установить, что документ исходит от стороны по договору и подписан уполномоченным лицом;
направления документов через согласованного Сторонами оператора путем использования системы электронного документооборота (ЭДО) – в день направления документа и информации Оператору направившей его Стороны. Порядок использования Сторонами ЭДО определяется дополнительным соглашением к Договору.
Договор заключен в двух экземплярах, обладающих равной юридической силой, по одному экземпляру для каждой Стороны.
# ПЕРЕЧЕНЬ ПРИЛОЖЕНИЙ К ДОГОВОРУ
# Договор включает следующие приложения, являющиеся его неотъемлемой частью:
# Приложение №1. Спецификация и стоимость Услуг.
# Приложение №2. Соглашение об уровне обслуживания.
# Приложение №3. Регламент взаимодействия Сторон в процессе оказания Услуг.
# Приложение №4. Технические условия.
# Приложение №5. Акт начала оказания услуг
# ЮРИДИЧЕСКИЕ АДРЕСА И РЕКВИЗИТЫ СТОРОН
| г. Москва | 01.02.2026г. |
|---|---|
| Заказчик Меридиан_0001 Юридический адрес: Генеральный директор _______________ /ФИО/ М.П. | Исполнитель Альянс_0001 Юридический адрес: 115404, г. Москва, Вн. Тер. Г. Муниципальный округ Бирюлево Восточное, ул. 1-я Стекольная, д.7 стр. 2 Почтовый адрес: 115404, г. Москва, ул. 1-я Стекольная, д.7с2 ИНН_0001, КПП_0001 ОГРН_0001 РС_0001 Прогресс_0001, г. Москва КС_0001 БИК: 044525225 e-mail email_0001 Web-site www.nubes.ru Тел. +7_000_000_0001 Генеральный директор _______________ /В. С. Степаненко/ М.П. |
|---|---|
Binary file not shown.
+57
View File
@@ -0,0 +1,57 @@
**Дополнительное Соглашение Договор_0001**
к Договору Договор_0003 от 20.12.2025
г. Москва «27» февраля 2026
**Альянс_0001** именуемое в дальнейшем "Заказчик", в лице Директора ФИО, действующего на основании Устава, и **ООО **"**НУБЕС**", именуемое в дальнейшем "Исполнитель", в лице Генерального директора Фёдоров_0001, действующего на основании Устава, с другой стороны, заключили настоящее Дополнительное соглашение Договор_0002 (далее - Соглашение) к Договору Договор_0005 от 29.12.2025 (далее - Договор) о нижеследующем:
**1. ПРЕДМЕТ ДОПОЛНИТЕЛЬНОГО СОГЛАШЕНИЯ**
Руководствуясь п. 15.7, п.15.9 Договора, Стороны договорились о нижеследующем:
Изменить Спецификацию и стоимость Услуг (Приложение Договор_0002 к Договору). Утвердить новую Спецификацию и стоимость Услуг в соответствии с Приложением Договор_0002 к настоящему Соглашению.
Датой начала оказания Услуг по Договору считать даты, указанные в Приложении № 1 к Соглашению.
**ПРОЧИЕ УСЛОВИЯ**
2.1. Во всём остальном, что не урегулировано настоящим Соглашением, действуют положения Договора.
**СРОК ДЕЙСТВИЯ**
Настоящее Соглашение подписано в двух экземплярах, обладающих равной юридической силой, по одному для каждой Стороны, вступает в силу с момента его подписания обеими Сторонами и является неотъемлемой частью Договора.
**4.** **ПЕРЕЧЕНЬ ПРИЛОЖЕНИЙ К СОГЛАШЕНИЮ**
Приложение № 1. Спецификация и стоимость услуг
**ЮРИДИЧЕСКИЕ АДРЕСА И РЕКВИЗИТЫ СТОРОН**
**Приложение Договор_0001**
к Дополнительному Соглашению Договор_0002 от «27» февраля 2026
к Договору Договор_0004 от 20.12.2025. на оказание технологических услуг
**Спецификация и стоимость услуг**
### Состав и стоимость Услуг
Разовые услуги
Общая стоимость Инсталляционных услуг составляет **8 283,80 руб. **(восемь тысяч двести восемьдесят три рубля, 80 копеек) с учетом НДС 22%.
Абонентские услуги
Общая стоимость Ежемесячных услуг составляет **137 880,96 руб**. (сто тридцать семь тысяч восемьсот восемьдесят рублей, 96 копеек) с учетом НДС 22%.
| Заказчик Сфера_0001 Юридический адрес: Почтовый адрес: ИНН , КПП ОГРН Р/с Кор/сч БИК: Тел. , e-mail Директор _______________ФИО М.П. | Исполнитель Проект_0001 Юридический адрес: Новосибирск_0001 Почтовый адрес: Красноярск_0001 ИНН_0001, КПП_0001 ОГРН_0001 РС_0001 Меридиан_0001, г. Москва КС_0001 БИК: БИК_0001 e-mail email_0001 Web-site www.nubes.ru Тел. +7_000_000_0001 Генеральный директор _______________ / Волков_0001/ М.П. |
|---|---|
| № п/п | Наименование услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Дата начала оказания услуг |
|---|---|---|---|---|---|
| 1 | Организация L2 канала без резерва, в составе: Скорость порта: 1 Гбит/с - 1 шт. Период тарификации - месяц | 8 283,80 | 1 | 8 283,80 | 26.03.2026 |
| Итого инсталляционный платеж в руб с учетом НДС | Итого инсталляционный платеж в руб с учетом НДС | Итого инсталляционный платеж в руб с учетом НДС | Итого инсталляционный платеж в руб с учетом НДС | 8 283,80 | - |
| № п/п | Наименование услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Дата начала оказания услуг | Дата окончания оказания услуг |
|---|---|---|---|---|---|---|
| 1 | Аренда порта без резерва, в составе: Скорость порта: 1 Гбит/с - 1 шт. Период тарификации - месяц | 1 525,00 | 1 | 1 525,00 | 26.03.2026 | |
| 2 | Аренда публичной подсети (Длина префикса: /30 - 1 IPv4 адрес) | 589,66 | 1 | 589,66 | 26.03.2026 | |
| 3 | Интернет без защиты от DDoS, в составе: Полоса Интернет: тариф от 5 до 100 Мбит/с - 100 Mбит/с Период тарификации - месяц | 7 015,00 | 1 | 7 015,00 | 26.03.2026 | |
| 4 | Next Generation Cloud (AMD 4.0 ГГц), в составе: Количество vCPU - 62 шт. Период тарификации - месяц Объем vRAM - 480 ГБайт Период тарификации - месяц Дисковое пространство: SATA - 720 ГБайт Период тарификации - месяц Дисковое пространство: Fast SSD - 6838 ГБайт Период тарификации - месяц | 123 823,00 | 1 | 123 823,00 | 26.03.2026 | |
| 5 | Next Generation Cloud (AMD 4.0 ГГц), в составе: Количество vCPU - 10 шт. Объем vRAM - 10 ГБайт Дисковое пространство: Fast SSD - 100 ГБайт | 4 928,30 | 1 | 4 928,30 | 29.01.2026 | |
| Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | 137 880,96 | - | |
| Заказчик Директор Сфера_0001 _______________/ФИО/ М.П. | Исполнитель Генеральный директор Комплекс_0001 _______________ /Волков_0001/ М.П. |
|---|---|
@@ -0,0 +1,52 @@
Приложение № 1
к Договору на оказание технологических услуг № 03700_1 от 01.02.2026 г.
**Спецификация и стоимость услуг**
### Состав и стоимость Услуг
Абонентские услуги с 01.04.2026 24.04.2026
Общая стоимость Ежемесячных услуг составляет **769 279,53 руб**. (семьсот шестьдесят девять тысяч двести семьдесят девять рублей, 53 копейки) с учетом НДС 22%.
Абонентские услуги с 25.04.2026
Общая стоимость Ежемесячных услуг составляет **877 563,21 руб**. (восемьсот семьдесят семь тысяч пятьсот шестьдесят три рубля, 21 копейка) с учетом НДС 22%.
| г. Москва | 01.02.2026г. |
|---|---|
| № п/п | Наименование услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Планируемая дата начала оказания услуг |
|---|---|---|---|---|---|
| 1 | Аренда стойко-места, в составе: Номинальная мощность – 10 кВт на каждую стойку 2H1Q010 2H1K080 2H1I030 Размер стойки: 600х1200 - 3 шт. | 213 905,44 | 3 | 641 716,32 | 01.04.2026 |
| 2 | Аренда PDU: 3Ф 32А вертикальный | 2 511,17 | 3 | 7 533,51 | 01.04.2026 |
| 3 | Аренда серверного шкафа: 42Ux600x1200 | 2 440,00 | 3 | 7 320,00 | 01.04.2026 |
| 4 | Аренда порта без резерва, в составе: Скорость порта: 1 Гбит/с - 3 шт. | 4 575,00 | 1 | 4 575,00 | 01.04.2026 |
| 5 | Предоставление 1 ОВ в кабеле ВОЛС: НУБЕС-М9 От Красноярск_0001 До Нижний_Новгород_0001 | 18 300,00 | 4 | 73 200,00 | 01.04.2026 |
| 6 | Аренда публичной подсети: Длина префикса: /29 - 5 IPv4 адресов | 884,50 | 1 | 884,50 | 01.04.2026 |
| 7 | Next Generation Cloud: AMD 2.8 ГГц, в составе: Количество vCPU: гарантированная доля 80% - 32 шт. Объем vRAM - 96 ГБайт Дисковое пространство: SSD - 2000 ГБайт | 34 050,20 | 1 | 34 050,20 | 01.04.2026 |
| Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | 769 279,53 | - |
| № п/п | Наименование услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Планируемая дата начала оказания услуг |
|---|---|---|---|---|---|
| 1 | Аренда стойко-места, в составе: Номинальная мощность – 10 кВт на каждую стойку 2H1Q010 2H1K080 2H1I030 Размер стойки: 600х1200 - 3 шт. | 250 000,00 | 3 | 750 000,00 | 25.04.2026 |
| 2 | Аренда PDU: 3Ф 32А вертикальный | 2 511,17 | 3 | 7 533,51 | 01.04.2026 |
| 3 | Аренда серверного шкафа: 42Ux600x1200 | 2 440,00 | 3 | 7 320,00 | 01.04.2026 |
| 4 | Аренда порта без резерва, в составе: Скорость порта: 1 Гбит/с - 3 шт. | 4 575,00 | 1 | 4 575,00 | 01.04.2026 |
| 5 | Предоставление 1 ОВ в кабеле ВОЛС: НУБЕС-М9 От Красноярск_0001 До Нижний_Новгород_0001 | 18 300,00 | 4 | 73 200,00 | 01.04.2026 |
| 6 | Аренда публичной подсети: Длина префикса: /29 - 5 IPv4 адресов | 884,50 | 1 | 884,50 | 01.04.2026 |
| 7 | Next Generation Cloud: AMD 2.8 ГГц, в составе: Количество vCPU: гарантированная доля 80% - 32 шт. Объем vRAM - 96 ГБайт Дисковое пространство: SSD - 2000 ГБайт | 34 050,20 | 1 | 34 050,20 | 01.04.2026 |
| Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | 877 563,21 | - |
| Заказчик Генеральный директор Прогресс_0001 ________________ /ФИО/ М.П. | Исполнитель Генеральный директор Вектор_0001 _______________ /В.С. Степаненко/ М.П. |
|---|---|
@@ -0,0 +1,29 @@
Приложение № 1
к Договору на оказание технологических услуг
Договор_0001 от 20.12.2025 г.
**Спецификация и стоимость услуг**
### Состав и стоимость Услуг
Абонентские услуги
Общая стоимость Абонентских услуг составляет **4 928,30 руб**. (четыре тысячи девятьсот двадцать восемь рублей, тридцать копеек) с учетом НДС 22% в месяц.
| Екатеринбург_0001 | 20.12.2025г. |
|---|---|
| № п/п | Наименование Абонентских услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Планируемая дата начала оказания услуг |
|---|---|---|---|---|---|
| 1 | Next Generation Cloud (AMD 4.0 ГГц), в составе: Количество vCPU - 10 шт. Период тарификации - месяц Объем vRAM - 10 ГБайт Период тарификации - месяц Дисковое пространство: Fast SSD - 100 ГБайт Период тарификации - месяц | 4 928,30 | 1 | 4 928,30 | 29.01.2026 |
| Итого абонентский платеж в руб. с учетом НДС | Итого абонентский платеж в руб. с учетом НДС | Итого абонентский платеж в руб. с учетом НДС | Итого абонентский платеж в руб. с учетом НДС | 4 928,30 | - |
| Заказчик Директор Стандарт_0001 ________________ ФИО | Исполнитель Генеральный директор Комплекс_0001 _______________ Попов_0001 М.П. |
|---|---|
@@ -0,0 +1,28 @@
Приложение № 1
к Договору Договор_0001 от 01.02.2026 г.
**Спецификация и стоимость услуг**
### Состав и стоимость Услуг
Абонентские услуги
Общая стоимость Ежемесячных услуг составляет **68 700,00 руб**. (шестьдесят восемь тысяч семьсот рублей(я), 0 копеек(a)) с учетом НДС 22% 12 388,52 (Двенадцать тысяч триста восемьдесят восемь рублей) 52 копейки.
| г. Москва | 01.02.2026г. |
|---|---|
| № п/п | Наименование услуг | Цена, руб. с НДС | Объем услуг | Сумма, руб. с НДС | Планируемая дата начала оказания услуг |
|---|---|---|---|---|---|
| 1 | Облачный диск Valo Cloud, в составе: Число пользователей - 300 шт. Период тарификации - месяц Дисковое пространство: Объем хранения - 6000 ГБайт Период тарификации - месяц | 68 700,00 | 1 | 68 700,00 | 01.02.2026 |
| Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | Итого абонентский платеж в руб с учетом НДС | 68 700,00 | - |
| Заказчик Генеральный директор Сервис_0001 ________________ ФИО. М.П. | Исполнитель Генеральный директор Меридиан_0001 _______________ Михайлов_0001 М.П. |
|---|---|
+324
View File
@@ -0,0 +1,324 @@
# Архитектура DrHider
## Общая схема
```
Пользователь (браузер)
https://drhider.pythonk8s.dev.nubes.ru/
Штурвал (Managed Flask на pythonk8s)
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()
TwoPassObfuscator
┌────────────┼────────────┐
│ │ │
extractor scanner replacer
(текст) (regex+LLM) (замена)
│ │ │
└────────────┼────────────┘
builder
(ZIP+CSV)
application/zip
```
---
## Поддерживаемые форматы файлов
| Формат | Обработка | Детали |
|--------|-----------|--------|
| **`.docx`** | → Markdown | Заголовки (H1H3), **жирный**, *курсив*, таблицы (`python-docx`) |
| **`.pdf`** | → Markdown | Текст + таблицы (`pdfplumber`), без форматирования |
| **`.txt`** и прочие текстовые | → как есть | Декодируется UTF-8, ошибки заменяются `` |
| **`.zip`** | → распаковка | Файлы внутри обрабатываются рекурсивно. Защита от ZIP-бомб: ≤500 файлов, ratio ≤100:1, ≤500 MB |
| **`.doc`** (бинарный) | ❌ не поддерживается | Штурвал не даёт установить LibreOffice. Если понадобится — просить админов платформы |
Ограничение на размер загрузки: **200 MB** (`MAX_CONTENT_LENGTH`).
---
## Двухпроходная обфускация
### Проход 1: сбор сущностей
```
Файлы (.docx, .pdf, .txt, .zip)
extractor.expand_zips() ← распаковать ZIP
extractor.convert_pdfs_to_docx() ← PDF → DOCX
extractor.extract_text() ← извлечь текст из каждого файла
├──→ scanner.scan_regex() ← regex: телефоны, email, ИНН, ОГРН, КПП,
│ БИК, счета, паспорта, компании, ФИО
└──→ scanner.scan_llm_ner() ← LLM: имена, адреса, паспорта
(только если llm_client передан)
mapping = {оригинал → фиктивное} ← глобальный словарь замен
```
### Проход 2: замена
```
mapping + sorted_keys (по убыванию длины)
Для каждого файла:
├── .docx → replacer.replace_in_docx() ← склеить runs → заменить → сохранить
├── .doc → без изменений (бинарный)
└── .txt/.pdf → replacer.replace_in_text() ← простая строковая замена
builder.build_mapping_csv() ← mapping.csv
builder.build_zip() ← ZIP со всеми файлами + mapping.csv
```
---
## Модули
### `drhider/config.py`
Константы уровня всего пакета. НЕ содержит логики — только данные.
- `ENTITY_PATTERNS` — 10 regex-паттернов
- `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)`
- `expand_zips(files)` → распакованный список
- `convert_pdfs_to_docx(files)` → PDF заменены на DOCX
### `drhider/scanner.py`
Две функции (не класс), мутируют переданный `mapping: Dict[str, str]`:
- `scan_regex(text, mapping)` — regex-поиск
- `scan_llm_ner(all_texts, mapping, llm_client)` — LLM NER
### `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`
Две функции сборки результата:
- `build_zip(files, mapping_csv)` → bytes (ZIP-архив)
- `build_mapping_csv(mapping)` → str (CSV)
### `drhider/obfuscator.py`
`TwoPassObfuscator` — оркестратор. Единственный класс, который знает о顺序е вызовов.
Метод `.obfuscate(files)` вызывает модули в правильном порядке.
`obfuscate_files()` — удобная функция-обёртка.
### `site/app.py`
Точка входа для Штурвала. `create_app()` создаёт Flask, регистрирует blueprint'ы.
`app = create_app()` — глобальный экземпляр.
### `site/routes/`
Blueprint'ы — каждый в своём файле:
- `main_bp` — только `GET /`
- `health_bp` — только `GET /health`
- `api_bp` — только `POST /api/drhider`
`__init__.py` содержит `register_routes(app)` — единая точка регистрации.
---
## Поток данных
```
HTTP POST /api/drhider (multipart/form-data)
api_bp.drhider()
│ request.files.getlist("files")
│ → [(filename, bytes, mimetype), ...]
obfuscate_files(files, llm_client=LLMClient())
│ Scanner → Extractor → LLM NER → Obfuscator → Replacer → Builder
bytes (ZIP-архив с обезличенными документами + mapping.csv)
```
---
## Требования к Flask-приложению для Штурвала
### 1. `/health` — liveness probe
```python
@health_bp.route("/health")
def health():
return jsonify({"ok": True, "version": "0.0.26"})
```
**Обязательно.** Штурвал дёргает `/health` для readiness/liveness checks. Без этого под не поднимется. Ответ должен быть `200 OK`.
### 2. `no_cache` на все ответы
```python
@app.after_request
def no_cache(response):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
```
**Критично для SPA-подобного UI.** Без этого nginx/браузер кэшируют страницу → F5 показывает старую версию, кнопки не работают, версия не обновляется.
### 3. `X-Accel-Buffering: no` для SSE
```python
return Response(
stream_with_context(generate()),
content_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
```
**Критично для SSE-прогресса.** Без этого nginx буферизует поток → прогресс не отображается в реальном времени, все события приходят одним блоком в конце.
### 4. `MAX_CONTENT_LENGTH`
```python
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB
```
Без этого Flask использует дефолтный лимит (~1MB) и отвергает docx/pdf.
### 5. `GeneratorExit` в SSE-генераторе
```python
try:
yield f"event: start\n..."
except GeneratorExit:
return # клиент отключился — не продолжаем обработку
```
**Критично.** При закрытии вкладки браузер рвёт SSE-соединение. Без `GeneratorExit` генератор продолжает слать запросы в LLM, тратя токены впустую.
### 6. `stream_with_context`
```python
from flask import stream_with_context
return Response(stream_with_context(generate()), ...)
```
**Обязательно для SSE.** Без этого Flask держит приложение заблокированным на время SSE-соединения, другие запросы не обрабатываются.
### 7. `sys.path` для импорта пакета drhider
```python
_sys_path_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
```
Штурвал запускает из `site/`, пакет `drhider/` на уровень выше. Без этого импорт не работает.
### 8. In-memory session storage
Сессии хранятся в `site/session.py` как `Dict[str, dict]`. **Не персистентны** — теряются при рестарте пода. Очистка по таймауту (2 часа).
### 9. LLM-клиент через переменные окружения
```python
LLM_KEY # API ключ
LLM_URL # эндпоинт (по умолчанию https://api.aillm.ru/v1/chat/completions)
LLM_MODEL # модель (по умолчанию gpt-oss-120b)
```
### 10. BOM в CSV
```python
buf.write('\ufeff'.encode('utf-8') + csv_str.encode('utf-8'))
```
BOM (`\ufeff`) нужен для корректного открытия CSV в Excel (чтобы русские буквы не ломались).
TwoPassObfuscator.obfuscate(files)
├── expand_zips → convert_pdfs_to_docx → extract_text
├── scan_regex + scan_llm_ner → mapping
├── replace_in_docx / replace_in_text → обфусцированные файлы
└── build_mapping_csv + build_zip → (zip_bytes, csv_str)
send_file(BytesIO(zip_data), mimetype="application/zip")
HTTP 200 + ZIP-архив
```
### Имена скачиваемых файлов
Формат: `drhider_YYYY-MM-DD_HH-MM-SS.zip` и `mapping_YYYY-MM-DD_HH-MM-SS.csv`
Время — московское (UTC+3). Генерируется на сервере в момент запроса:
```python
# api_bp.py — download() / csv_download()
ts = (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d_%H-%M-%S")
download_name=f"drhider_{ts}.zip" # → Content-Disposition
download_name=f"mapping_{ts}.csv" # → Content-Disposition
```
Фронтенд (JS) читает имя из заголовка `Content-Disposition`, **не** хардкодит.
---
## Принципы
1. **Модули — чистые функции.** Где возможно — без классов, без состояния.
2. **Единственный класс — TwoPassObfuscator.** Только он управляет состоянием (mapping, sorted_keys).
3. **Flask — тонкая прослойка.** Только принимает запрос, вызывает `obfuscate_files()`, отдаёт ответ.
4. **Без БД.** Всё в памяти. Никаких внешних зависимостей кроме LLM API.
5. **⛔ Ноль внешних зависимостей на фронтенде.** Никаких CDN, внешних шрифтов, иконок, JS-библиотек. ВСЁ в коде. Страница грузится за 1 запрос.
6. **⛔ ВСЯ обработка данных — на бэкенде.** Фронтенд только отображает и скачивает. Никакой JS-логики для ZIP, парсинга, обфускации. Один запрос → один ответ.
7. **⛔ Пофайловая обработка — на бэкенде.** Файлы обрабатываются строго последовательно (по одному), без параллельных LLM-запросов. Прогресс — бэкенд сообщает статус каждого файла.
+193
View File
@@ -0,0 +1,193 @@
# Деплой, ВМ и переменные окружения
---
## Платформа
**Штурвал** — Managed Flask на pythonk8s (Kubernetes).
- Запускает Flask **сам** — без Dockerfile, без gunicorn
- Точка входа: `site/app.py` → глобальная переменная `app`
- Деплой: `git push origin master` → авто-деплой через UI Штурвала
---
## Деплой
```bash
cd /home/naeel/nubes/drhider
git add -A
git commit -m "описание изменений"
git push origin master
```
После пуша — зайти в UI Штурвала и нажать ** Redeploy** для `drhider`.
---
## Переменные окружения (настраиваются в UI Штурвала)
| Переменная | Значение | Для чего |
|---|---|---|
| `LLM_API_KEY` | `sk-ucI5YvOticoOQ9Kuj5K9mQ` | Ключ доступа к LLM API |
| `LLM_URL` | `https://api.aillm.ru/v1/chat/completions` | URL LLM API |
| `LLM_MODEL` | `gpt-oss-120b` | Модель для NER |
Без `LLM_API_KEY` LLM-сканирование не работает (только regex).
---
## ВМ contracts (legacy)
Старый сервер contracts.kube5s.ru. DrHider там — standalone HTTP-сервер на порту 8767.
### SSH
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213
```
### Где лежит старый drhider
```
/home/naeel/contracts/drhider/
├── drhider.py # Ядро обфускации (отсюда скопировано в этот проект)
├── drhider_server.py # HTTP-сервер :8767 (НЕ ИСПОЛЬЗУЕТСЯ в новом проекте)
└── __init__.py
```
### Сервисы на ВМ
| Сервис | Порт | systemd unit |
|---|---|---|
| drhider | 8767 | `contracts-drhider` |
| convert_server | 8766 | `contracts` |
| Flask UI | 5001 | — (start.sh) |
| nginx | 443 | `nginx` |
| PostgreSQL | 5432 | — |
### Управление drhider на ВМ
```bash
# Статус
systemctl status contracts-drhider
# Логи
journalctl -u contracts-drhider -f
# Перезапуск
sudo systemctl restart contracts-drhider
```
---
## Kubernetes-кластер (iot-naeel)
DrHider работает в кластере `iot-naeel` на платформе Штурвал.
### Доступ к kubectl
kubectl настроен на ВМ `5.172.178.213`. Все команды выполняются через SSH:
```bash
ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213 kubectl ...
```
### DrHider
```bash
# Найти под drhider (namespace — UUID Штурвала)
kubectl get pods --all-namespaces | grep pythonk8s
# Логи
kubectl logs -n <namespace> <pod>
# Перезапуск — через UI Штурвала (Redeploy)
```
### Ingress
```bash
# Реплики
kubectl get deploy -n ingress shturval-ingress-controller-controller
# externalTrafficPolicy
kubectl get svc -n ingress shturval-ingress-controller-controller \
-o jsonpath='{.spec.externalTrafficPolicy}'
# Масштабирование (если сбросилось)
kubectl scale deploy -n ingress shturval-ingress-controller-controller --replicas=4
```
### Cilium / MTU
```bash
# Текущий MTU
kubectl get configmap -n kube-system cilium-config -o jsonpath='{.data.mtu}'
# Статус Cilium
kubectl -n kube-system exec ds/cilium -- cilium-dbg status | grep -i mtu
```
### VIP (kube-vip)
VIP кластера: `185.247.187.151`. Раздаётся через kube-vip (ARP) на все 4 ноды.
---
## Локальная разработка
```bash
cd /home/naeel/nubes/drhider
# Установка зависимостей
pip install -r requirements.txt
# Запуск Flask (dev-сервер)
cd site && python3 app.py
# Проверка синтаксиса ВСЕХ файлов
python3 -c "
import py_compile, os
for root, dirs, files in os.walk('.'):
for f in files:
if f.endswith('.py'):
path = os.path.join(root, f)
py_compile.compile(path, doraise=True)
print(f'OK: {path}')
"
# Проверка импорта
python3 -c "from drhider import obfuscate_files, LLMClient; print('OK')"
```
---
## Как править код
1. Понять что меняем → **описать план**
2. Получить **«делай»**
3. Внести изменения
4. `python3 -c "import py_compile; py_compile.compile('файл.py', doraise=True)"` — КАЖДЫЙ изменённый .py
5. `python3 -c "from drhider import obfuscate_files, LLMClient"` — проверка импорта
6. `git add -A && git commit -m "подробно что и зачем" && git push`
7. Записать в `History/` что сделано
8. Зайти в UI Штурвала → Redeploy
---
## Как добавить новый генератор
1. Создать `drhider/generators/новый_тип.py` с функцией `generate_xxx(original: str) -> str`
2. Добавить regex-паттерн в `drhider/config.py``ENTITY_PATTERNS`
3. Добавить импорт и маппинг в `drhider/generators/__init__.py``ENTITY_GENERATORS`
4. Проверить синтаксис → commit → push → Redeploy
---
## История версий
| Версия | Дата | Что |
|---|---|---|
| 2.0.0 | 2026-07-12 | Модульный рефакторинг (22 модуля вместо монолита) |
| 1.0.0 | 2026-07-11 | Начальная версия (monolith drhider.py + Flask) |
+104
View File
@@ -0,0 +1,104 @@
# Порядок действий при изменении кода
**Читать перед ЛЮБОЙ правкой.**
---
## ⛔ ЗАПРЕЩЕНО
1. Менять код без «делай»
2. Действовать по догадкам («я бы сделал так», «можно попробовать»)
3. Удалять файлы/папки без разрешения
4. «Улучшать» рабочий код без прямого разрешения
5. Использовать `sed`
6. Откладывать commit/push «на потом»
7. Игнорировать проверку синтаксиса перед push
---
## ✅ ПОРЯДОК ПРИ ЛЮБОМ ИЗМЕНЕНИИ
### Шаг 1 — План
```
Описать что меняем → получить «делай»
```
### Шаг 2 — Правка
Вносим изменения. Только то, что обговорено. Никакой самодеятельности.
### Шаг 3 — Проверка синтаксиса
```bash
python3 -c "import py_compile; py_compile.compile('путь/к/файлу.py', doraise=True)"
```
**КАЖДЫЙ изменённый .py файл.**
### Шаг 4 — Проверка импорта
```bash
cd /home/naeel/nubes/drhider && python3 -c "from drhider import obfuscate_files, LLMClient; print('OK')"
```
### Шаг 5 — Проверить соседей
После `replace_string_in_file` — прочитать соседние строки, убедиться что не затёр соседнюю функцию/класс.
### Шаг 6 — commit + push (СРАЗУ)
```bash
cd /home/naeel/nubes/drhider
git add -A
git commit -m "подробное описание что и зачем"
git push
```
**Без исключений. Никаких накоплений.**
### Шаг 7 — Документировать
Записать в `History/` что сделано:
- Отдельный `.md` файл
- Что планировалось → что сделано → в чём ошибся
### Шаг 8 — Redeploy
Зайти в UI Штурвала → Redeploy `drhider`.
---
## 🔧 Команды в терминале — всегда с таймаутами
```bash
curl --max-time 30 ...
ssh -o ConnectTimeout=10 ...
timeout 10 grep ... file.txt
```
---
## 📦 Проверка ВСЕХ файлов разом
```bash
cd /home/naeel/nubes/drhider
python3 -c "
import py_compile, os
for root, dirs, files in os.walk('.'):
for f in files:
if f.endswith('.py'):
py_compile.compile(os.path.join(root, f), doraise=True)
print(f'OK: {os.path.join(root, f)}')
"
```
---
## 🏷️ Версионирование
Версия в `site/app.py` (переменная `VERSION`).
Менять при любом изменении кода, влияющем на поведение.
---
## 📝 Пример хорошего коммита
```
git commit -m "generators: добавил generate_snils — генератор фиктивных СНИЛС
- Новый файл drhider/generators/snils.py
- Добавлен паттерн в config.py → ENTITY_PATTERNS
- Добавлен в generators/__init__.py → ENTITY_GENERATORS
- bump VERSION 2.0.0 → 2.0.1"
```
+134
View File
@@ -0,0 +1,134 @@
# Варианты решения ingress replicas + TCP stall
**Дата:** 2026-07-13
**Источники:** Sonnet analysis, Opus analysis
**Связано:** [KUBEVIP-EXTERNAL-TRAFFIC-POLICY.md](KUBEVIP-EXTERNAL-TRAFFIC-POLICY.md)
---
## Ключевой вывод (оба согласны)
Проблемы **две независимые**, решать по отдельности:
1. **TCP stall 51с** — PMTU Blackhole на Geneve-туннеле, а не свойство `Cluster`-политики. `Local` лечит как побочный эффект (убирает cross-node).
2. **Сброс реплик**`kubectl scale` обходит Helm, поэтому реконсиляция откатывает.
> ⚠️ **Важно (Opus):** `kubectl scale` вы обходите Helm, поэтому и сбрасывается. Любое итоговое решение должно быть в **values чарта**, а не через `kubectl`.
---
## Варианты
### 1. DaemonSet (рекомендуется)
Одно изменение в Helm values:
```yaml
controller:
kind: DaemonSet
# replicaCount игнорируется
```
- ✅ Автоматически 1 под/ноду
- ✅ Helm не ломает
- ✅ Стандарт для bare-metal / kube-vip
- ✅ Не требует менять сетевую конфигурацию
### 2. Починить MTU (убрать stall в корне)
51с — это классический Path MTU Discovery Blackhole. Geneve: MTU 1500 → ~1450.
**Диагностика:**
```bash
ip link show | grep -E "cilium|genev"
iptables -t mangle -L FORWARD -n -v | grep TCPMSS
```
**Исправление:**
```bash
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu
```
Если stall устранён → можно вернуть `externalTrafficPolicy: Cluster` и replicaCount 2.
### 3. Cilium DSR (Direct Server Return)
Ответный трафик идёт напрямую от пода к клиенту, без cross-node SNAT.
```yaml
# cilium-config (требует kube-proxy replacement = strict)
loadBalancer:
mode: dsr
kubeProxyReplacement: strict
```
- ❌ Долго, меняет всю сетевую конфигурацию
### 4. replicaCount: 4 + podAntiAffinity
Если Штурвал не даёт сменить `kind` на DaemonSet:
```yaml
controller:
replicaCount: 4
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
topologyKey: kubernetes.io/hostname
```
- ✅ Гарантирует 1 под/ноду
- ✅ Helm values зафиксированы, не сбрасывается
---
## Рекомендуемый путь (Sonnet + Opus)
| Приоритет | Действие | Эффект |
|-----------|----------|--------|
| 🟢 Сразу | `controller.kind: DaemonSet` в Helm values | Убирает костыль с репликами навсегда |
| 🟡 Параллельно | Диагностика MTU + MSS clamping | Устраняет stall в корне |
| 🔵 Потом | Если MTU починен — вернуть `Cluster` | Можно уйти от `Local` и требования под/ноду |
## Как передать девопсу (Opus)
Для чарта `shturval-ingress-controller-2.12.1`:
```bash
# Вариант A: DaemonSet
helm upgrade -n ingress shturval-ingress-controller <чарт> \
--set controller.kind=DaemonSet
# Вариант B: replicaCount + antiAffinity (если DaemonSet недоступен)
helm upgrade -n ingress shturval-ingress-controller <чарт> \
--set controller.replicaCount=4 \
--set controller.affinity.podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution[0].labelSelector.matchLabels.app\.kubernetes\.io/name=ingress-nginx \
--set controller.affinity.podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution[0].topologyKey=kubernetes.io/hostname
```
Либо через Штурвал UI — изменить `replicaCount: 4` в настройках чарта.
## Диагностика MTU (Opus)
```bash
# реальный MTU туннеля
ip link show | grep -E "cilium|genev"
# есть ли MSS clamping
iptables -t mangle -L FORWARD -n -v | grep MSS
```
Варианты фикса:
- MSS clamping: `iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu`
- Cilium DSR: `loadBalancer.mode: dsr` + `kubeProxyReplacement: strict`
- Выставить MTU в Cilium под физическую сеть (Geneve overhead ~50 байт)
---
## Текущий статус
`externalTrafficPolicy: Local` — не сбрасывается.
`replicas: 4` — ручной scale, периодически сбрасывается Helm на 2.
+87
View File
@@ -0,0 +1,87 @@
# Kube-vip + externalTrafficPolicy: Local + ingress replicas
**Дата:** 2026-07-13
**Кластер:** iot-naeel
**Сервис:** `ingress/shturval-ingress-controller-controller`
---
## Проблема
`externalTrafficPolicy: Local` на ingress-сервисе + 2 ingress-пода на 4 нодах.
Kube-vip раздаёт VIP (`185.247.187.151`) на **все 4 ноды** через ARP.
С `Local` трафик, пришедший на ноду БЕЗ ingress-пода — **дропается**.
Результат: 50% запросов → `ERR_TIMED_OUT`.
## Почему не было раньше
Раньше стоял `externalTrafficPolicy: Cluster` — kube-vip форвардил трафик
на любую ноду с ingress через Geneve-туннель. F5 работал, но при передаче
**больших файлов** возникал TCP stall на 51 секунду (cross-node Geneve).
## Хронология
1. `Cluster` → всё работало, но большие файлы висли на 51с
2. `Cluster``Local` — файлы перестали виснуть, но F5 стал дропаться (реплик 2)
3. `kubectl scale --replicas=4` — обе проблемы решены
4. **2026-07-13 10:24 MSK** — Helm-релиз ревизия 6 → `replicas` сброшены с 4 на 2
5. `kubectl scale --replicas=4` — снова 4
## Почему сбрасывается
Ingress управляется Helm-чартом `shturval-ingress-controller-2.12.1`.
```yaml
# helm get values -n ingress shturval-ingress-controller
controller:
replicaCount: 2 # ← вот причина
```
Штурвал периодически реконсилит деплой под Helm-значения —
видит 4 → возвращает на 2.
`externalTrafficPolicy: Local` при этом **не сбрасывается** (ставилось через `kubectl patch svc`, не через Helm).
## Что нужно от девопса
**Единственное изменение:** `replicaCount: 2 → 4` в Helm-чарте ingress.
```
helm upgrade -n ingress shturval-ingress-controller <чарт> --set controller.replicaCount=4
```
Или DaemonSet.
## Текущее состояние (исправлено вручную, будет сброшено)
```bash
kubectl scale deploy -n ingress shturval-ingress-controller-controller --replicas=4
```
| Под | Нода |
|-----|------|
| `j4z6z` | iot-naeel-control-plane-xb699 |
| `q8v8g` | iot-naeel-workers-vqphm-6f74n |
| `zqxqg` | iot-naeel-workers-vqphm-bhbvs |
| `fp4p2` | iot-naeel-workers-vqphm-v8zq4 |
## Результат
10 последовательных запросов: 10/10 HTTP 200, 130-250ms.
## Что нужно от девопса
1. Перевести ingress на **DaemonSet** — автоматически по поду на ноду
2. Или `replicas = max(2, количество нод)` — автоскейлинг при добавлении нод
3. При передеплое Штурвала `replicas` не должен сбрасываться на 2
## Ноды (4)
| Нода | Тип |
|------|-----|
| iot-naeel-control-plane-xb699 | control-plane |
| iot-naeel-workers-vqphm-6f74n | workers |
| iot-naeel-workers-vqphm-bhbvs | workers |
| iot-naeel-workers-vqphm-v8zq4 | workers |
+288
View File
@@ -0,0 +1,288 @@
# Инструкция: перенос UI и загрузки файлов с ВМ на Flask (Штурвал)
**Дата:** 2026-07-14
**Основано на:** drhider v0.0.26 — проверено в бою
---
## Архитектура загрузки (drhider как образец)
```
Браузер (index.html)
│ JS: uploadFiles() — цикл по файлам
│ XHR POST /api/upload (FormData, по одному файлу)
Flask (api_bp.py)
│ upload(): сохраняет в сессию → {session_id}
│ process_stream(): SSE — обработка с прогрессом
Python (drhider/*.py)
│ obfuscate_files() — основная логика
Flask → ZIP + CSV → браузеру
```
## Файлы, отвечающие за UI и загрузку
### 1. `site/templates/index.html` — весь фронтенд
**Ключевые элементы:**
```html
<!-- Файловый инпут — множественный выбор -->
<input type="file" id="fileInput" multiple accept=".docx,.pdf,.txt,.zip">
<!-- Таблица выбранных файлов -->
<table>
<tbody id="fileList"></tbody>
</table>
<!-- Статус-бар -->
<div class="status" id="status"></div>
<!-- Кнопки скачивания (скрыты до готовности) -->
<div class="dl-btns" id="dlBtns">
<button onclick="downloadZip()">📦 Скачать ZIP</button>
<button onclick="downloadCsv()">📋 Скачать CSV</button>
</div>
```
**Ключевые JS-функции:**
| Функция | Что делает |
|---------|------------|
| `resetAll()` | Сброс всего состояния (F5, между загрузками) |
| `rr()` | Перерисовка таблицы файлов |
| `uploadFiles()` | **Главная** — цикл загрузки + SSE-обработка |
| `ss(idx, html)` | Обновление ячейки статуса в таблице |
| `downloadZip()` / `downloadCsv()` | Скачивание результатов |
**Критичные исправления (v0.0.26 — v0.0.29):**
```javascript
// 1. Прогрев upstream при загрузке страницы — обязательно!
window.addEventListener('load', () => {
sf = []; fi.value = ''; rr();
fetch('/health').catch(() => {}); // ← обязательно! греет upstream
});
// 2. Abort предыдущих запросов перед новым
let activeES = null; // активный EventSource
let activeXHR = null; // активный XHR
// 3. Защита от F5 во время загрузки
window.addEventListener('beforeunload', () => resetAll());
// 4. Сохранение списка файлов до очистки
const files = sf.slice(); // копия перед resetAll()
// 5. Выбор файлов — ДОБАВЛЯЕТ в таблицу (не стирает, с дедупом)
fi.addEventListener('change', () => {
const incoming = Array.from(fi.files);
const seen = new Set(sf.map(f => f.name + '|' + f.size));
for (const f of incoming) {
if (!seen.has(f.name + '|' + f.size)) sf.push(f);
}
const d = new DataTransfer();
sf.forEach(f => d.items.add(f));
fi.files = d.files;
rr();
});
// 6. Цикл загрузки (fetch, не XHR — XHR давал RST на первом запросе)
for (let i = 0; i < total; i++) {
const fd = new FormData();
fd.append('files', f, f.name);
if (currentSid) fd.append('session', currentSid);
const resp = await fetch('/api/upload?_=' + Date.now(), { // ← fetch, не XHR
method: 'POST', body: fd
});
const data = await resp.json();
currentSid = data.session;
}
// 7. SSE-обработка
activeES = new EventSource('/api/process_stream/' + currentSid);
activeES.addEventListener('start', ...); // файл начат
activeES.addEventListener('done', ...); // файл готов
activeES.addEventListener('complete', ...); // всё готово
// 8. Скачивание ZIP/CSV — через fetch+Blob (не window.location!)
function downloadZip() {
if (!currentSid) return;
fetch('/api/download/' + currentSid)
.then(r => {
const disp = r.headers.get('Content-Disposition');
const m = disp && disp.match(/filename="?(.+?)"?$/);
return Promise.all([r.blob(), m ? m[1] : 'result.zip']);
})
.then(([blob, fname]) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fname;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
});
}
```
### 2. `site/routes/api_bp.py` — API эндпоинты
```
POST /api/upload — загрузка одного файла → {session_id}
GET /api/process_stream/<sid> — SSE: обработка с прогрессом
POST /api/process/<sid> — обработка без SSE (legacy)
GET /api/download/<sid> — скачать ZIP
GET /api/csv/<sid> — скачать CSV отдельно
```
**Критичные заголовки для SSE:**
```python
return Response(
stream_with_context(generate()),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no" # ← без этого nginx буферизует SSE
}
)
```
**Критично: GeneratorExit в SSE-генераторе:**
```python
def generate():
for idx, (fname, content) in enumerate(files):
try:
yield f"event: start\ndata: ...\n\n"
except GeneratorExit:
return # клиент отключился — не обрабатываем дальше
# ... обработка файла ...
try:
yield f"event: done\ndata: ...\n\n"
except GeneratorExit:
return
```
### 3. `site/routes/health_bp.py` — liveness probe
```python
@health_bp.route("/health")
def health():
return jsonify({"ok": True, "version": "0.0.1"})
```
**Обязательно для Штурвала.**
### 4. `site/app.py` — точка входа
**Критичные настройки:**
```python
VERSION = "0.0.1"
def create_app():
app = Flask(__name__)
app.config["VERSION"] = VERSION
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB
from routes import register_routes
register_routes(app)
@app.after_request
def no_cache(response):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
return app
app = create_app()
```
---
## Пошаговая инструкция миграции
### Шаг 1: Скопировать скелет UI
Скопировать `site/templates/index.html` из drhider как основу.
Заменить:
- Заголовок (`<title>`, `.title`, `.card-header`)
- Текст описания
- `accept` в `<input type="file">` если другие форматы
- Логику в `uploadFiles()` — вызов своего API вместо `obfuscate_files`
### Шаг 2: Адаптировать API
Скопировать `site/routes/api_bp.py`, заменить:
- Название blueprint'а
- Логику в `process_stream()` — вызов своей функции вместо `obfuscate_files()`
- Формат SSE-событий если нужен другой
### Шаг 3: Health endpoint
Скопировать `site/routes/health_bp.py` как есть. Только версию поменять.
### Шаг 4: app.py
Скопировать `site/app.py`, заменить:
- `VERSION`
- `MAX_CONTENT_LENGTH` если нужен другой лимит
- Импорт своих blueprint'ов
### Шаг 5: session.py
Скопировать `site/session.py` как есть. Это in-memory хранилище загруженных файлов и результатов.
### Шаг 6: Интеграция бизнес-логики
Твоя функция обработки должна принимать тот же интерфейс что и `obfuscate_files`:
```python
def твоя_функция(files, **kwargs):
"""
Args:
files: list of (filename: str, content: bytes, mimetype: str)
Returns:
zip_bytes: bytes — ZIP-архив с результатами
csv_str: str — CSV с маппингом (или "")
"""
```
---
## Чек-лист перед деплоем
- [ ] `/health` возвращает `{"ok": true}`
- [ ] `MAX_CONTENT_LENGTH` достаточен для файлов
- [ ] `no_cache` after_request есть
- [ ] `X-Accel-Buffering: no` на SSE-эндпоинте
- [ ] `GeneratorExit` в каждом `yield` SSE-генератора
- [ ] `stream_with_context` оборачивает генератор
- [ ] `fetch('/health')` при загрузке страницы
- [ ] `beforeunload``resetAll()`
- [ ] `activeXHR` и `activeES` очищаются перед новым запуском
- [ ] `fetch` вместо XHR (XHR на первом запросе даёт RST)
- [ ] Выбор файлов — добавляет, не стирает (с дедупом)
- [ ] `Connection: close`**НЕ ставить** (Waitress/PEP 3333 запрещает hop-by-hop)
---
## Ссылки на файлы-образцы
| Что | Где в drhider |
|-----|---------------|
| HTML/JS/CSS фронтенд | `site/templates/index.html` |
| API (upload + SSE + download) | `site/routes/api_bp.py` |
| Health probe | `site/routes/health_bp.py` |
| Точка входа Flask | `site/app.py` |
| In-memory сессии | `site/session.py` |
| Полная архитектура | `docs/ARCHITECTURE.md` |
| Решённые проблемы кластера | `PROBLEM-AND-SOLUTION.md` |
+90
View File
@@ -0,0 +1,90 @@
# Корневое решение: MSS clamping через Cilium
**Дата:** 2026-07-13 (обновлено после валидации Opus)
**Статус:** План для девопса
**Валидация:** [Opus response](../History/2026-07-13-opus-response-mss-clamping.md)
---
## Текущий костыль
`externalTrafficPolicy: Local` + `kubectl scale replicas=4` (сбрасывается Штурвалом каждые ~час).
## Корень проблемы
Geneve-туннель добавляет ~50 байт → превышение underlay MTU → ICMP Frag Needed блокируется → TCP stall 51с.
## Решение
Выставить корректный MTU в Cilium, чтобы пакеты с Geneve-заголовком всегда влазили.
**Важно:** `mtu` в cilium-config — это MTU **underlay-сети** (физической/VM-сети между нодами). Cilium сам вычитает 50 байт на Geneve:
```
mtu: X → туннель = X − 50 → поды = X 50
```
## Что сделать девопсу
## Замер (выполнен 2026-07-13)
```bash
kubectl run mtu-test --rm -it --restart=Never --image=nicolaka/netshoot -- ping -c 2 -M do -s 1472 172.16.0.9
# → Message too large (1500 не проходит)
kubectl run mtu-test --rm -it --restart=Never --image=nicolaka/netshoot -- ping -c 2 -M do -s 1422 172.16.0.9
# → 1430 bytes from 172.16.0.9 (1450 проходит)
**Результат:** underlay MTU = **1450**, туннель = **1400**.
### Шаг 1: Выставить MTU в Cilium
```bash
kubectl edit configmap -n kube-system cilium-config
# mtu: <измеренное значение>
```
### Шаг 2: Применить изменения
```bash
# Перезапустить агенты Cilium (ConfigMap сам не применяется)
kubectl rollout restart ds/cilium -n kube-system
# Пересоздать поды приложения (ingress, drhider — чтобы veth получили новый MTU)
kubectl rollout restart deploy -n ingress shturval-ingress-controller-controller
```
### Шаг 3: Вернуть Cluster policy
```bash
kubectl patch svc -n ingress shturval-ingress-controller-controller \
-p '{"spec":{"externalTrafficPolicy":"Cluster"}}'
```
### Шаг 4: Верификация
```bash
# Проверить что Cilium применил MTU
kubectl -n kube-system exec ds/cilium -- cilium-dbg status | grep -i mtu
ip link show | grep -E 'cilium|geneve'
# Проверить MSS в SYN-пакетах
tcpdump -ni any 'tcp[tcpflags] & tcp-syn != 0' -vv | grep mss
```
Функционально:
- Загрузить файл > 5 MB через drhider — без паузы 51с
- F5 несколько раз — без ERR_TIMED_OUT
- Проверить Keycloak и другие сервисы
## Почему это грамотно
- Решает корневую причину (PMTU blackhole), не симптомы
- Не трогает логику Штурвала (ingress остаётся на 2 репликах)
- Работает для всего кластера
- Стандартная практика для туннельного CNI
## Примечания
- **iptables как fallback ненадёжен:** Cilium с eBPF может обходить netfilter FORWARD. Если нужно — правило на **всех 4 нодах**, но лучше через `cilium-config`.
- **Понижение MTU безопасно** (меньше = консервативнее). Максимум — микро-потеря throughput.
- **Не смешивать с проблемой реплик.** При `Cluster` 2 реплик достаточно — ручной scale не нужен.
+5
View File
@@ -0,0 +1,5 @@
НЕ СПЕШИ ! не ошибайся. НЕ ПРЕДПОЛАГАЙ !
не надо ДОГАДОК !
не надо самостоятельно что либо "УЛуЧШАТЬ" - никаких изменений рабочего кода без прямого разрешения
есть сомнения - лучше остановись и спроси
ВСЁ записывай в хистори ! что планировал что сделал в чём ошибся и тд
+75
View File
@@ -0,0 +1,75 @@
# Почему Штурвал не рассчитан на underlay 1450
Ответы Соннета, 2026-07-14.
---
## Почему дефолтный MTU 1500, а не 1400
Штурвал — managed-платформа. Разработчики не знают MTU конкретной площадки.
- **Стандартный дата-центр:** underlay 1500 или Jumbo Frames 9000. Pod MTU 1500 + Geneve 50 = 1550 — легко влезает.
- **Nubes — исключение:** underlay 1450 (физика 1500 + свой VXLAN 50 байт для SDN). Штурвал не тестировался конкретно на этой площадке.
- **Снижать MTU глобально «на всякий случай» — плохо:** MTU 1400 снижает пропускную способность на ~7%. Для большинства клиентов это ненужная жертва.
Проблема флапает (при удачном размещении подов работает), поэтому жалобы могли не дойти до разработчиков.
**Вывод:** классическое «работает на наших серверах». Не баг в коде, а пробел в документации/онбординге.
---
## Почему underlay именно 1450
Это признак VXLAN:
```
Outer Ethernet: 14 б
Outer IP: 20 б
UDP: 8 б
VXLAN header: 8 б
─────────────────────
Итого: 50 б
```
Физика у Nubes — стандартные 1500. Поверх — VXLAN для SDN (мультитенантность, изоляция). Арендаторам остаётся 1500 − 50 = 1450.
**Почему не Jumbo Frames 9000:** Jumbo Frames требуют чтобы все коммутаторы, сетевые карты и топология поддерживали их. Настраивается при строительстве ЦОД. Скорее всего Nubes — арендованные мощности или исторически не включили. 9000 — привилегия крупных облаков (AWS, GCP), владеющих своим железом от и до.
**Как проверить на ноде:**
```bash
# Пройдёт если MTU ≥1500 (1472 + 28 заголовков = 1500)
ping -M do -s 1472 <ip_шлюза>
# Пройдёт при 1450 (1422 + 28 = 1450), упадёт на 1423
ping -M do -s 1422 <ip_шлюза>
ping -M do -s 1423 <ip_шлюза> # должен дать "Frag needed"
```
---
## Почему 1400 а не ниже
Geneve-туннели **независимы**. Каждый переход — отдельная инкапсуляция:
```
Pod отправляет 1400 байт
→ Нода A → Нода B: 1400 + 50 (Geneve) = 1450 ✅
→ Нода B → Нода C: снова 1400 + 50 = 1450 ✅
```
Второй туннель берёт **расшифрованный** пакет (снова 1400) и заново оборачивает. Не накапливается. Если бы накапливалось — нужно было бы 1350 (1350+50+50=1450). Но нет — 1400 достаточно для любого количества переходов.
---
## Что сказать девопсам
> Привет! Столкнулся с проблемой при работе managed-сервисов на Штурвале.
>
> У Nubes underlay MTU = 1450 (стандарт 1500 − 50 байт своего VXLAN). При дефолтном Cilium MTU 1500 любой cross-node трафик через Geneve даёт пакет 1550 — не влазит, HTTP POST зависает на ~51с.
>
> Решение: `mtu: 1400` в Cilium при создании кластера. 1400 + 50 (Geneve) = 1450 — ровно в канал. Даже два Geneve-перехода — достаточно (туннели независимы).
>
> Трогать существующие кластеры не предлагаю — там риск. Но можно добавить в шаблон/чеклист для новых кластеров на Nubes.
Тон — коллегиальный. Не «вы облажались», а «нашёл нюанс, вот решение, давайте зафиксируем».
-557
View File
@@ -1,557 +0,0 @@
"""
DrHider — обфускация документов (двухпроходная, в памяти, без БД).
Проход 1: собрать все сущности из всех файлов → глобальный словарь замен.
Проход 2: применить замены → собрать ZIP с обфусцированными файлами + mapping.csv.
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
"""
DRHIDER_VERSION = "1.3"
import io
import csv
import re
import random
import string
import zipfile
import logging
import os
from typing import Dict, List, Tuple, Callable, Optional
log = logging.getLogger("drhider")
# ═══════════════════════════════════════════
# Regex-паттерны для обнаружения сущностей
# ═══════════════════════════════════════════
ENTITY_PATTERNS: Dict[str, str] = {
"phone": r'(?<!\d)(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}(?!\d)',
"email": r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b',
# 12-значный ИНН ДО 10-значного (иначе 12-значный матчится как 10)
"inn_fl": r'ИНН\s*\d{12}',
"inn_ul": r'ИНН\s*\d{10}',
"ogrn": r'ОГРН\s*\d{13}',
"kpp": r'КПП\s*\d{9}',
"bik": r'БИК\s*\d{9}',
"rs": r'(?:р/с|расч[её]тный\s*сч[её]т)\s*\d{20}',
"ks": r'(?:к/с|кор/сч|корр?[еи]?спондентский\s*сч[её]т)\s*\d{20}',
"passport": r'(?:паспорт|серия\s+номер)[\s:№]*\d{2}\s*\d{2}\s*\d{6,7}',
}
# Фирмы — обнаружение по шаблону
COMPANY_PATTERN = re.compile(
r'(?:ООО|ЗАО|ОАО|АО|ПАО|ИП|ТОО)\s+(?:«[^»]+»|"[^"]+"|\u201c[^\u201d]+\u201d|[А-ЯA-Z][\w\-\.]{2,40})',
re.IGNORECASE
)
# ФИО — Фамилия + инициалы или полное имя (только кириллица)
PERSON_PATTERN = re.compile(
r'\b[А-Я][а-я]+\s+[А-Я]\.[А-Я]\.'
r'|\b[А-Я][а-я]+\s+[А-Я][а-я]+\s+[А-Я][а-я]+'
)
# ═══════════════════════════════════════════
# Генераторы фиктивных значений
# ═══════════════════════════════════════════
# Словари русских имён
RU_SURNAMES = ["Иванов", "Смирнов", "Кузнецов", "Попов", "Васильев", "Петров",
"Соколов", "Михайлов", "Новиков", "Фёдоров", "Морозов", "Волков", "Алексеев",
"Лебедев", "Семёнов", "Егоров", "Павлов", "Козлов", "Степанов", "Николаев"]
RU_NAMES = ["Александр", "Дмитрий", "Сергей", "Андрей", "Алексей", "Максим",
"Евгений", "Иван", "Михаил", "Николай", "Владимир", "Павел", "Виктор", "Олег"]
RU_PATRONYMICS = ["Александрович", "Дмитриевич", "Сергеевич", "Андреевич",
"Алексеевич", "Иванович", "Михайлович", "Николаевич", "Владимирович",
"Павлович", "Викторович", "Олегович", "Евгеньевич", "Максимович"]
RU_CITIES = ["Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
"Казань", "Нижний Новгород", "Челябинск", "Самара", "Омск", "Ростов-на-Дону",
"Уфа", "Красноярск", "Воронеж", "Пермь", "Волгоград"]
RU_STREETS = ["Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная"]
FAKE_DOMAINS = ["example.ru", "mail.test", "company.local", "org.example.ru"]
def _checksum_inn10(inn: str) -> str:
"""Контрольная сумма для 10-значного ИНН."""
coeffs = [2, 4, 10, 3, 5, 9, 4, 6, 8]
s = sum(int(inn[i]) * coeffs[i] for i in range(9))
return str((s % 11) % 10)
def _checksum_inn12(inn: str) -> str:
"""Контрольные суммы для 12-значного ИНН (первые 10 цифр)."""
c1 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
c2 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
s1 = sum(int(inn[i]) * c1[i] for i in range(10))
n1 = (s1 % 11) % 10
inn2 = inn + str(n1)
s2 = sum(int(inn2[i]) * c2[i] for i in range(11))
n2 = (s2 % 11) % 10
return str(n1) + str(n2)
def _checksum_ogrn(ogrn: str) -> str:
"""Контрольная сумма для ОГРН (12 цифр → остаток от деления на 11)."""
s = int(ogrn) % 11
return str(s % 10)
def _random_digits(n: int) -> str:
return ''.join(random.choice(string.digits) for _ in range(n))
def _random_letters(n: int) -> str:
return ''.join(random.choice(string.ascii_lowercase) for _ in range(n))
def generate_phone(_: str) -> str:
code = random.choice(["495", "499", "812", "383", "343"])
return f"+7 ({code}) {_random_digits(3)}-{_random_digits(2)}-{_random_digits(2)}"
def generate_email(original: str) -> str:
"""Генерирует email с тем же форматом."""
domain = random.choice(FAKE_DOMAINS)
local = _random_letters(random.randint(5, 10))
return f"{local}@{domain}"
def generate_inn10(original: str) -> str:
prefix = re.match(r'ИНН\s*', original, re.IGNORECASE).group(0)
base = f"{random.randint(1,9)}{_random_digits(8)}"
return prefix + base + _checksum_inn10(base)
def generate_inn12(original: str) -> str:
prefix = re.match(r'ИНН\s*', original, re.IGNORECASE).group(0)
base = f"{random.randint(1,9)}{_random_digits(9)}"
return prefix + base + _checksum_inn12(base)
def generate_ogrn(original: str) -> str:
prefix = re.match(r'ОГРН\s*', original, re.IGNORECASE).group(0)
base = "1" + _random_digits(11)
return prefix + base + _checksum_ogrn(base)
def generate_kpp(original: str) -> str:
prefix = re.match(r'КПП\s*', original, re.IGNORECASE).group(0)
return prefix + _random_digits(4) + random.choice(["01", "43", "77"]) + _random_digits(3)
def generate_bik(original: str) -> str:
prefix = re.match(r'БИК\s*', original, re.IGNORECASE).group(0)
return prefix + "04" + _random_digits(7)
def generate_rs(original: str) -> str:
prefix = re.match(r'(?:р/с|расч[её]тный\s*сч[её]т)\s*', original, re.IGNORECASE).group(0)
return prefix + "40702" + _random_digits(15)
def generate_ks(original: str) -> str:
prefix = re.match(r'(?:к/с|кор/сч|корр?[еи]?спондентский\s*сч[её]т)\s*', original, re.IGNORECASE).group(0)
return prefix + "30101" + _random_digits(15)
def generate_passport(original: str) -> str:
m = re.match(r'(?:паспорт|серия\s+номер)[\s:№]*', original, re.IGNORECASE)
prefix = m.group(0) if m else ""
return prefix + f"{random.randint(10,99)} {random.randint(10,99)} {_random_digits(6)}"
def generate_company(_: str) -> str:
forms = ["ООО", "ЗАО", "АО"]
nouns = ["Технология", "Прогресс", "Гарант", "Стандарт", "Импульс",
"Вектор", "Сфера", "Альянс", "Синтез", "Меридиан", "Спектр", "Формат"]
return f'{random.choice(forms)} «{random.choice(nouns)}»'
def generate_person(_: str) -> str:
s = random.choice(RU_SURNAMES)
n = random.choice(RU_NAMES)
p = random.choice(RU_PATRONYMICS)
return f"{s} {n[0]}.{p[0]}."
def generate_address(_: str) -> str:
city = random.choice(RU_CITIES)
street = random.choice(RU_STREETS)
house = random.randint(1, 200)
return f"{city}, ул. {street}, д. {house}"
# ═══════════════════════════════════════════
# Основной класс
# ═══════════════════════════════════════════
class TwoPassObfuscator:
"""Двухпроходный обфускатор: сбор сущностей → замена."""
def __init__(self, llm_client=None):
self._mapping: Dict[str, str] = {} # оригинал → замена (только для точных текстовых совпадений)
self._regex_replacements: List[Tuple[str, str, Callable]] = [] # (pattern, type, generator)
self._sorted_keys: List[str] = [] # ключи mapping, отсортированные по длине (убывание)
self._llm_client = llm_client
def obfuscate(self, files: List[Tuple[str, bytes, str]]) -> Tuple[bytes, str]:
"""
Главная точка входа.
Args:
files: [(original_filename, content_bytes, content_type), ...]
Returns:
(zip_bytes, mapping_csv_string)
"""
# --- Распаковать ZIP-файлы ---
files = self._expand_zips(files)
# --- Конвертировать PDF → DOCX ---
files = self._convert_pdfs_to_docx(files)
try:
# --- Проход 1: сбор сущностей ---
all_texts: Dict[str, str] = {}
all_docx: Dict[str, object] = {}
for fname, content, ctype in files:
text, doc = self._extract_text(fname, content, ctype)
all_texts[fname] = text
if doc is not None:
all_docx[fname] = doc
if text and text != "[DOC binary — not parsed]":
self._scan_regex(text)
if self._llm_client:
self._scan_llm_ner(all_texts)
# Предсортировать ключи один раз для _apply_replacements
self._sorted_keys = sorted(self._mapping.keys(), key=len, reverse=True)
# --- Проход 2: замена ---
results = []
for fname, content, ctype in files:
obf_content = content
if fname.endswith('.doc'):
# .doc — бинарный, оставляем как есть
pass
elif fname in all_docx:
obf_content = self._replace_in_docx(all_docx[fname])
else:
txt = all_texts.get(fname, '')
obf_content = self._replace_in_text(txt, fname)
results.append((fname, obf_content))
csv_str = self._build_mapping_csv()
return self._build_zip(results, csv_str), csv_str
finally:
self._mapping.clear()
self._regex_replacements.clear()
self._sorted_keys.clear()
def _convert_pdfs_to_docx(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Конвертировать PDF в DOCX через pdfplumber. При совпадении имён — _из_pdf."""
import pdfplumber
from docx import Document as DocxDocument
result = []
existing_names = {f[0] for f in files}
for fname, content, ctype in files:
if not fname.lower().endswith('.pdf'):
result.append((fname, content, ctype))
continue
try:
doc = DocxDocument()
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
tables = page.extract_tables()
for table in tables:
if table:
rows = [[str(c or "").strip() for c in (row or [])] for row in table]
rows = [r for r in rows if any(r)]
if rows:
t = doc.add_table(rows=len(rows), cols=len(rows[0]))
t.style = 'Table Grid'
for ri, row in enumerate(rows):
for ci, cell_text in enumerate(row):
t.rows[ri].cells[ci].text = cell_text
text = page.extract_text()
if text:
for line in text.split('\n'):
line = line.strip()
if line:
doc.add_paragraph(line)
buf = io.BytesIO()
doc.save(buf)
new_name = fname[:-4] + '.docx'
if new_name in existing_names:
new_name = fname[:-4] + '_из_pdf.docx'
existing_names.add(new_name)
result.append((new_name, buf.getvalue(), ctype))
except Exception as e:
log.warning("PDF→DOCX error for %s: %s", fname, e)
result.append((fname, content, ctype))
return result
def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть.
Защита от ZIP-бомб: ratio + накопительный размер (как в compare/unzip.py)."""
result = []
for fname, content, ctype in files:
if fname.lower().endswith('.zip'):
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
if len(zf.infolist()) > 500:
log.warning("ZIP too many files, skipping: %s", fname)
result.append((fname, content, ctype))
continue
total_uncompressed = 0
for info in zf.infolist():
if info.is_dir():
continue
# ZIP bomb: ratio check
if info.compress_size > 0:
ratio = info.file_size / info.compress_size
if ratio > 100:
log.warning("ZIP bomb ratio %.0f:1, skipping: %s", ratio, fname)
result.append((fname, content, ctype))
break
# cp437 → utf8 (как в compare/unzip.py)
name = info.filename
try:
name = name.encode("cp437").decode("utf-8", errors="replace")
except (UnicodeDecodeError, UnicodeEncodeError):
pass
# Убрать path traversal
name = os.path.basename(name)
if not name or name.endswith("/") or ".." in name or "/" in name or "\\" in name:
continue
inner_data = zf.read(info)
total_uncompressed += len(inner_data)
if total_uncompressed > 500 * 1024 * 1024: # 500 MB
log.warning("ZIP uncompressed limit exceeded, stopping: %s", fname)
break
result.append((name, inner_data, ""))
except Exception as e:
log.warning("Failed to expand ZIP %s: %s", fname, e)
result.append((fname, content, ctype))
else:
result.append((fname, content, ctype))
return result
# --- Проход 1: обнаружение ---
def _extract_text(self, fname: str, content: bytes, ctype: str) -> Tuple[str, Optional[object]]:
"""Извлечь текст из файла. Возвращает (text, docx_document_or_None)."""
doc = None
text = ""
ext = os.path.splitext(fname)[1].lower()
if ext == '.docx':
try:
from docx import Document
except ImportError:
text = content.decode('utf-8', errors='replace')
return text, None
doc = Document(io.BytesIO(content))
text = "\n".join(p.text for p in doc.paragraphs)
for table in doc.tables:
for row in table.rows:
text += "\n" + " | ".join(cell.text for cell in row.cells)
elif ext == '.pdf':
try:
import pdfplumber
except ImportError:
text = content.decode('utf-8', errors='replace')
return text, None
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
t = page.extract_text()
if t:
text += t + "\n"
for table in page.extract_tables():
for row in table:
text += "\n" + " | ".join(str(c) if c else "" for c in row)
elif ext == '.doc':
# .doc — бинарный формат, без libreoffice не парсим
# Пропускаем без изменений (не обфусцируем)
text = "[DOC binary — not parsed]"
return text, None
else:
text = content.decode('utf-8', errors='replace')
return text, doc
def _scan_regex(self, text: str):
"""Сканировать текст regex-паттернами, заполнить словарь замен."""
for entity_type, pattern in ENTITY_PATTERNS.items():
for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE):
original = match.group(0).strip()
if original and original not in self._mapping:
generator = ENTITY_GENERATORS.get(entity_type, lambda x: "XXX")
self._mapping[original] = generator(original)
# Компании (кроме НУБЕС — это Исполнитель)
for match in COMPANY_PATTERN.finditer(text):
original = match.group(0).strip()
if original and original not in self._mapping:
if re.search(r'НУБЕС|NUBES', original, re.IGNORECASE):
continue # Исполнитель — не заменяем
self._mapping[original] = generate_company(original)
# ФИО (Фамилия И.О.)
for match in PERSON_PATTERN.finditer(text):
original = match.group(0).strip()
if original and original not in self._mapping:
self._mapping[original] = generate_person(original)
def _scan_llm_ner(self, all_texts: Dict[str, str]):
"""LLM NER для обнаружения имён и адресов во всех файлах."""
combined = "\n\n---FILE---\n\n".join(
f"FILE: {fname}\n{t[:3000]}" for fname, t in all_texts.items()
)
prompt = (
"Ты — система обнаружения персональных данных в документах. "
"Найди ВСЕ следующие сущности в тексте ниже:\n\n"
"1. ФИО (полные и сокращённые — 'Иванов И.И.', 'Петров А.С.')\n"
"2. Названия компаний-контрагентов (не 'НУБЕС')\n"
"3. Почтовые адреса\n"
"4. Паспортные данные\n\n"
"Формат ответа — JSON-массив:\n"
'[{"type": "person"|"company"|"address"|"passport", "value": "найденный текст"}]\n\n'
f"Текст:\n{combined[:8000]}"
)
try:
raw = self._llm_client.complete(prompt)
import json
# Игнорируем markdown-обёртку
raw = raw.strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1]
if raw.endswith("```"):
raw = raw[:-3]
entities = json.loads(raw)
for ent in entities:
val = ent.get("value", "").strip()
if val and val not in self._mapping:
if ent.get("type") == "person":
self._mapping[val] = generate_person(val)
elif ent.get("type") == "company":
self._mapping[val] = generate_company(val)
elif ent.get("type") == "address":
self._mapping[val] = generate_address(val)
elif ent.get("type") == "passport":
self._mapping[val] = generate_passport(val)
except Exception as e:
log.warning("LLM NER failed: %s", e)
# --- Проход 2: замена ---
def _replace_in_docx(self, doc) -> bytes:
"""Заменить сущности в docx. Склеиваем runs → заменяем → пишем в первый run, очищаем остальные."""
for para in doc.paragraphs:
if not para.runs:
continue
full_text = "".join(run.text for run in para.runs)
replaced = self._apply_replacements(full_text)
if replaced != full_text:
para.runs[0].text = replaced
for run in para.runs[1:]:
run.text = ""
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
if not para.runs:
continue
full_text = "".join(run.text for run in para.runs)
replaced = self._apply_replacements(full_text)
if replaced != full_text:
para.runs[0].text = replaced
for run in para.runs[1:]:
run.text = ""
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
def _replace_in_text(self, text: str, fname: str) -> bytes:
"""Заменить сущности в plain text (для PDF и прочих)."""
replaced = self._apply_replacements(text)
# Для PDF пока отдаём текст (MVP — без сохранения форматирования PDF)
return replaced.encode('utf-8')
def _apply_replacements(self, text: str) -> str:
"""Применить все замены из словаря mapping к строке. Сначала длинные, потом короткие."""
result = text
for original in self._sorted_keys:
replacement = self._mapping[original]
# Границы слова — если сущность начинается и заканчивается на \w
if original and original[0].isalnum() and original[-1].isalnum():
pattern = r'(?<!\w)' + re.escape(original) + r'(?!\w)'
else:
pattern = re.escape(original)
result = re.sub(pattern, replacement, result)
return result
# --- Сборка выдачи ---
def _build_zip(self, files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes:
"""Собрать ZIP с обфусцированными файлами + mapping.csv."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for fname, content in files:
info = zipfile.ZipInfo(fname)
info.flag_bits |= 0x800 # UTF-8 filename
zf.writestr(info, content)
if mapping_csv:
zf.writestr("mapping.csv", '\ufeff'.encode('utf-8') + mapping_csv.encode('utf-8'))
return buf.getvalue()
def _build_mapping_csv(self) -> str:
"""Собрать mapping.csv."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["тип_данных", "оригинал", "замена"])
for original, replacement in sorted(self._mapping.items()):
etype = "text"
# inn_fl ДО inn_ul (12 цифр vs 10)
for t in ["phone", "email", "inn_fl", "inn_ul", "ogrn", "kpp", "bik", "rs", "ks", "passport"]:
pat = ENTITY_PATTERNS.get(t, "")
if pat and re.match(pat, original, re.IGNORECASE):
etype = t
break
if COMPANY_PATTERN.match(original):
etype = "company"
writer.writerow([etype, original, replacement])
return buf.getvalue()
# ═══════════════════════════════════════════
# Маппинг entity_type → генератор
# ═══════════════════════════════════════════
ENTITY_GENERATORS: Dict[str, Callable] = {
"phone": generate_phone,
"email": generate_email,
"inn_ul": generate_inn10,
"inn_fl": generate_inn12,
"ogrn": generate_ogrn,
"kpp": generate_kpp,
"bik": generate_bik,
"rs": generate_rs,
"ks": generate_ks,
"passport": generate_passport,
}
def obfuscate_files(files: List[Tuple[str, bytes, str]], llm_client=None) -> Tuple[bytes, str]:
"""Удобная функция: обфусцировать список файлов → (zip_bytes, csv_string)."""
obf = TwoPassObfuscator(llm_client=llm_client)
return obf.obfuscate(files)
+13
View File
@@ -0,0 +1,13 @@
"""
DrHider — обфускация документов (двухпроходная, в памяти, без БД).
Проход 1: собрать все сущности из всех файлов → глобальный словарь замен.
Проход 2: применить замены → собрать ZIP с обфусцированными файлами + mapping.csv.
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
"""
from .obfuscator import TwoPassObfuscator, obfuscate_files
from .llm_client import LLMClient
__all__ = ["TwoPassObfuscator", "obfuscate_files", "LLMClient"]
+98
View File
@@ -0,0 +1,98 @@
"""
Сборка результата обфускации.
Два метода:
1. _build_zip — упаковка обфусцированных файлов в ZIP
2. _build_mapping_csv — генерация mapping.csv с таблицей замен
"""
import io
import csv
import zipfile
import re
import time
from typing import Dict, List, Tuple
from .config import ENTITY_PATTERNS, COMPANY_PATTERN
def build_zip(files: List[Tuple[str, bytes]], mapping_csv: str = "") -> bytes:
"""Собрать ZIP-архив с обфусцированными файлами.
В архив добавляются:
- Все обфусцированные файлы (с оригинальными именами)
- mapping.csv — таблица соответствия оригинал→замена (если не пустая)
Имена файлов в архиве — UTF-8 (бит 11 в flag_bits).
Args:
files: [(filename, content_bytes), ...]
mapping_csv: Строка CSV с таблицей замен (опционально)
Returns:
Бинарное содержимое ZIP-архива
"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
# Добавляем обфусцированные файлы
for fname, content in files:
info = zipfile.ZipInfo(fname)
info.date_time = time.localtime(time.time() + 3 * 3600)[:6] # MSK (UTC+3)
info.flag_bits |= 0x800 # Флаг: имя файла в UTF-8
zf.writestr(info, content)
# Добавляем mapping.csv с BOM (для корректного открытия в Excel)
if mapping_csv:
zf.writestr(
"mapping.csv",
'\ufeff'.encode('utf-8') + mapping_csv.encode('utf-8'),
)
return buf.getvalue()
def build_mapping_csv(mapping: Dict[str, str]) -> str:
"""Собрать mapping.csv — таблицу соответствия оригинал → замена.
Колонки:
- тип_данных: тип сущности (phone, email, inn_ul, company, ...)
- оригинал: исходное значение из документа
- замена: фиктивное значение
Тип определяется проверкой каждого значения через ENTITY_PATTERNS
и COMPANY_PATTERN. Если ни один паттерн не подошёл — тип "text".
Args:
mapping: Словарь {оригинал: замена}
Returns:
Строка в формате CSV
"""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["тип_данных", "оригинал", "замена"])
# Порядок проверки типов: inn_fl ДО inn_ul (12 цифр vs 10)
type_order = [
"phone", "email", "inn_fl", "inn_ul", "ogrn",
"kpp", "bik", "rs", "ks", "passport",
]
for original, replacement in sorted(mapping.items()):
# Определяем тип сущности
etype = "text" # По умолчанию
for t in type_order:
pat = ENTITY_PATTERNS.get(t, "")
if pat and re.match(pat, original, re.IGNORECASE):
etype = t
break
# Компании проверяем отдельно (COMPANY_PATTERN не в ENTITY_PATTERNS)
if COMPANY_PATTERN.match(original):
etype = "company"
writer.writerow([etype, original, replacement])
return buf.getvalue()
+116
View File
@@ -0,0 +1,116 @@
"""
Константы и словари для DrHider.
Содержит:
- ENTITY_PATTERNS — regex-паттерны для обнаружения сущностей
- COMPANY_PATTERN — паттерн для обнаружения названий компаний
- PERSON_PATTERN — паттерн для обнаружения ФИО
- Словари русских имён, городов, улиц
- Список фиктивных доменов для email
"""
import re
# ═══════════════════════════════════════════════════════════════════════════
# Regex-паттерны для обнаружения сущностей в тексте документов
# ═══════════════════════════════════════════════════════════════════════════
ENTITY_PATTERNS = {
"phone": r'(?<!\d)(?:\+7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}(?!\d)',
"email": r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b',
# 12-значный ИНН ДО 10-значного — иначе 12-значный матчится как 10-значный
"inn_fl": r'ИНН\s*\d{12}',
"inn_ul": r'ИНН\s*\d{10}',
"ogrn": r'ОГРН\s*\d{13}',
"kpp": r'КПП\s*\d{9}',
"bik": r'БИК\s*\d{9}',
"rs": r'(?:р/с|расч[её]тный\s*сч[её]т)\s*\d{20}',
"ks": r'(?:к/с|кор/сч|корр?[еи]?спондентский\s*сч[её]т)\s*\d{20}',
"passport": r'(?:паспорт|серия\s+номер)[\s:№]*\d{2}\s*\d{2}\s*\d{6,7}',
}
# ═══════════════════════════════════════════════════════════════════════════
# Паттерны для LLM-независимого обнаружения (regex)
# ═══════════════════════════════════════════════════════════════════════════
# Названия компаний: ООО/ЗАО/АО/ПАО/ИП/ТОО + название в кавычках или без
COMPANY_PATTERN = re.compile(
r'(?:ООО|ЗАО|ОАО|АО|ПАО|ИП|ТОО)\s+(?:«[^»]+»|"[^"]+"|\u201c[^\u201d]+\u201d|[А-ЯA-Z][\w\-\.]{2,40})',
re.IGNORECASE,
)
# ФИО: Фамилия + инициалы (Иванов И.И.) или полное имя (Иванов Иван Иванович)
PERSON_PATTERN = re.compile(
r'\b[А-Я][а-я]+[\s\xa0]+[А-Я]\.[А-Я]\.'
r'|\b[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+[\s\xa0]+[А-Я][а-я]+',
)
# ═══════════════════════════════════════════════════════════════════════════
# Словари для генерации фиктивных значений
# ═══════════════════════════════════════════════════════════════════════════
# Русские фамилии (20 самых распространённых)
RU_SURNAMES = [
"Иванов", "Смирнов", "Кузнецов", "Попов", "Васильев", "Петров",
"Соколов", "Михайлов", "Новиков", "Фёдоров", "Морозов", "Волков",
"Алексеев", "Лебедев", "Семёнов", "Егоров", "Павлов", "Козлов",
"Степанов", "Николаев",
]
# Русские мужские имена
RU_NAMES = [
"Александр", "Дмитрий", "Сергей", "Андрей", "Алексей", "Максим",
"Евгений", "Иван", "Михаил", "Николай", "Владимир", "Павел",
"Виктор", "Олег",
]
# Русские отчества
RU_PATRONYMICS = [
"Александрович", "Дмитриевич", "Сергеевич", "Андреевич",
"Алексеевич", "Иванович", "Михайлович", "Николаевич", "Владимирович",
"Павлович", "Викторович", "Олегович", "Евгеньевич", "Максимович",
]
# Города России (15 крупнейших)
RU_CITIES = [
"Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
"Казань", "Нижний Новгород", "Челябинск", "Самара", "Омск",
"Ростов-на-Дону", "Уфа", "Красноярск", "Воронеж", "Пермь", "Волгоград",
]
# Названия улиц
RU_STREETS = [
"Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная",
]
# Фиктивные домены для генерации email
FAKE_DOMAINS = ["example.ru", "mail.test", "company.local", "org.example.ru"]
# ═══════════════════════════════════════════════════════════════════════════
# Пулы для гибридных токенов (шаблон + номер)
# ═══════════════════════════════════════════════════════════════════════════
# Существительные для названий компаний (20)
COMPANY_NOUNS = [
"Технология", "Прогресс", "Гарант", "Стандарт", "Импульс",
"Вектор", "Сфера", "Альянс", "Синтез", "Меридиан",
"Спектр", "Формат", "Модуль", "Ресурс", "Платформа",
"Система", "Комплекс", "Проект", "Решение", "Сервис",
]
# Города для адресов (20)
CITY_POOL = [
"Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург",
"Казань", "Нижний_Новгород", "Челябинск", "Самара", "Омск",
"Ростов-на-Дону", "Уфа", "Красноярск", "Воронеж", "Пермь",
"Волгоград", "Краснодар", "Саратов", "Тюмень", "Иркутск", "Хабаровск",
]
# Улицы для адресов (20)
STREET_POOL = [
"Ленина", "Мира", "Пушкина", "Гагарина", "Советская",
"Кирова", "Октябрьская", "Молодёжная", "Садовая", "Центральная",
"Школьная", "Лесная", "Полевая", "Строителей", "Набережная",
"Парковая", "Солнечная", "Зелёная", "Юбилейная", "Весенняя",
]
+310
View File
@@ -0,0 +1,310 @@
"""
Извлечение текста из документов — все форматы → Markdown.
Поддерживает:
- .docx → MD (python-docx: стили, жирный/курсив, таблицы)
- .pdf → MD (pdfplumber: текст + таблицы, без форматирования)
- .doc → MD (LibreOffice headless: .doc → .docx → штатный docx_to_markdown)
- .txt и прочие → как есть
Также содержит:
- expand_zips — распаковка ZIP с защитой от ZIP-бомб
"""
import io
import os
import zipfile
import tempfile
import shutil
import subprocess
import logging
from typing import List, Tuple
log = logging.getLogger("drhider")
# ═══════════════════════════════════════════════════════════════════════════
# DOCX → Markdown
# ═══════════════════════════════════════════════════════════════════════════
def docx_to_markdown(content: bytes) -> str:
"""Конвертировать DOCX в Markdown с сохранением базового форматирования.
Маппинг стилей:
- Heading 1/2/3 → # / ## / ###
- Bold → **текст**
- Italic → *текст*
- Таблицы → | col1 | col2 | (с разделителем)
Args:
content: Бинарное содержимое .docx файла
Returns:
Строка в формате Markdown
"""
from docx import Document
doc = Document(io.BytesIO(content))
lines = []
for para in doc.paragraphs:
if not para.text.strip():
lines.append("") # Пустая строка — разделитель абзацев
continue
text = para.text
# Применяем форматирование из runs (bold, italic)
if para.runs:
formatted_parts = []
for run in para.runs:
part = run.text
if run.bold:
part = f"**{part}**"
if run.italic:
part = f"*{part}*"
formatted_parts.append(part)
text = "".join(formatted_parts)
# Стиль абзаца
style_name = para.style.name if para.style else ""
if style_name.startswith("Heading"):
# Heading 1 → #, Heading 2 → ##, ...
try:
level = int(style_name.split()[-1])
except (ValueError, IndexError):
level = 1
prefix = "#" * min(level, 6)
lines.append(f"{prefix} {text}")
else:
lines.append(text)
# ── Таблицы ──
for table in doc.tables:
lines.append("") # Отступ перед таблицей
for ri, row in enumerate(table.rows):
cells = [cell.text.replace("\n", " ").strip() for cell in row.cells]
lines.append("| " + " | ".join(cells) + " |")
# Разделитель после первой строки (заголовка таблицы)
if ri == 0:
lines.append("|" + "|".join(["---"] * len(cells)) + "|")
lines.append("") # Отступ после таблицы
return "\n".join(lines)
# ═══════════════════════════════════════════════════════════════════════════
# PDF → Markdown
# ═══════════════════════════════════════════════════════════════════════════
def pdf_to_markdown(content: bytes) -> str:
"""Конвертировать PDF в Markdown (только текст + таблицы, без форматирования).
pdfplumber не различает стили (bold/italic/heading), поэтому
всё извлекается как plain text.
Args:
content: Бинарное содержимое .pdf файла
Returns:
Строка в формате Markdown (plain text + таблицы)
"""
import pdfplumber
lines = []
with pdfplumber.open(io.BytesIO(content)) as pdf:
for page in pdf.pages:
# ── Текст страницы ──
text = page.extract_text()
if text:
lines.append(text)
lines.append("")
# ── Таблицы ──
tables = page.extract_tables()
for table in tables:
if not table:
continue
# Чистим строки таблицы
clean_rows = [
[str(c or "").strip() for c in (row or [])]
for row in table
]
clean_rows = [r for r in clean_rows if any(r)]
if not clean_rows:
continue
# Выводим таблицу в MD
for ri, row in enumerate(clean_rows):
lines.append("| " + " | ".join(row) + " |")
if ri == 0:
lines.append("|" + "|".join(["---"] * len(row)) + "|")
lines.append("")
return "\n".join(lines)
# ═══════════════════════════════════════════════════════════════════════════
# DOC → Markdown (через сервис liberta)
# ═══════════════════════════════════════════════════════════════════════════
def doc_to_markdown(content: bytes) -> str:
"""Конвертировать бинарный .doc → .docx (сервис liberta) → Markdown.
Отправляет .doc на HTTP-сервис конвертации, получает .docx,
затем прогоняет через штатный docx_to_markdown().
Args:
content: Бинарное содержимое .doc файла
Returns:
Строка в формате Markdown (или сообщение об ошибке)
"""
import httpx
url = os.environ.get(
"CONVERT_SERVICE_URL",
"https://liberta.containerk8s.dev.nubes.ru/convert"
)
try:
with httpx.Client(timeout=120) as client:
resp = client.post(
url,
files={"file": ("input.doc", content, "application/msword")}
)
if resp.status_code == 200:
return docx_to_markdown(resp.content)
err = resp.json().get("error", f"HTTP {resp.status_code}")
log.warning("liberta conversion failed: %s", err)
return f"[DOC — conversion failed: {err}]"
except httpx.TimeoutException:
return "[DOC — conversion timed out (>120s)]"
except Exception as e:
log.warning("liberta error: %s", e)
return f"[DOC — conversion error: {e}]"
# ═══════════════════════════════════════════════════════════════════════════
# Единая точка входа
# ═══════════════════════════════════════════════════════════════════════════
def extract_text(fname: str, content: bytes, ctype: str = "") -> str:
"""Извлечь текст из одного файла → Markdown.
Args:
fname: Имя файла (с расширением)
content: Бинарное содержимое файла
ctype: MIME-тип (зарезервировано, не используется)
Returns:
Строка в формате Markdown
"""
ext = os.path.splitext(fname)[1].lower()
# ── .docx: полный Markdown с форматированием ──
if ext == '.docx':
try:
return docx_to_markdown(content)
except ImportError:
return content.decode('utf-8', errors='replace')
# ── .pdf: текст + таблицы ──
elif ext == '.pdf':
try:
return pdf_to_markdown(content)
except ImportError:
return content.decode('utf-8', errors='replace')
# ── .doc: бинарный → LibreOffice → DOCX → Markdown ──
elif ext == '.doc':
return doc_to_markdown(content)
# ── .txt и прочие ──
else:
return content.decode('utf-8', errors='replace')
# ═══════════════════════════════════════════════════════════════════════════
# ZIP-распаковка (без изменений)
# ═══════════════════════════════════════════════════════════════════════════
def expand_zips(files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Распаковать ZIP-файлы в списке, заменив их содержимым.
Не-ZIP файлы проходят без изменений.
Защита от ZIP-бомб:
- Максимум 500 файлов в архиве
- Ratio file_size/compress_size не более 100:1
- Накопительный размер распакованных данных не более 500 MB
Args:
files: [(filename, content_bytes, content_type), ...]
Returns:
Новый список файлов (ZIP раскрыты, остальные как есть)
"""
result: List[Tuple[str, bytes, str]] = []
for fname, content, ctype in files:
if not fname.lower().endswith('.zip'):
result.append((fname, content, ctype))
continue
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
if len(zf.infolist()) > 500:
log.warning("ZIP too many files, skipping: %s", fname)
result.append((fname, content, ctype))
continue
total_uncompressed = 0
for info in zf.infolist():
if info.is_dir():
continue
if info.compress_size > 0:
ratio = info.file_size / info.compress_size
if ratio > 100:
log.warning(
"ZIP bomb ratio %.0f:1, skipping: %s", ratio, fname
)
result.append((fname, content, ctype))
break
name = info.filename
try:
name = name.encode("cp437").decode("utf-8", errors="replace")
except (UnicodeDecodeError, UnicodeEncodeError):
pass
name = os.path.basename(name)
if (
not name
or name.endswith("/")
or ".." in name
or "/" in name
or "\\" in name
):
continue
inner_data = zf.read(info)
total_uncompressed += len(inner_data)
if total_uncompressed > 500 * 1024 * 1024:
log.warning(
"ZIP uncompressed limit exceeded, stopping: %s", fname
)
break
result.append((name, inner_data, ""))
except Exception as e:
log.warning("Failed to expand ZIP %s: %s", fname, e)
result.append((fname, content, ctype))
return result
+64
View File
@@ -0,0 +1,64 @@
"""
LLM-клиент для DrHider.
Используется в TwoPassObfuscator._scan_llm_ner() для обнаружения
имён, компаний, адресов и паспортных данных через LLM API.
Интерфейс:
client = LLMClient()
result = client.complete(prompt) # str
"""
import os
import httpx
class LLMClient:
"""Клиент для вызова LLM API (aillm.ru / OpenAI-совместимый).
Использует переменные окружения:
LLM_API_KEY или LLM_KEY — ключ API
LLM_URL — URL эндпоинта (по умолчанию https://api.aillm.ru/v1/chat/completions)
LLM_MODEL — модель (по умолчанию gpt-oss-120b)
"""
def __init__(self):
"""Инициализировать клиент. httpx импортируется лениво."""
self._httpx = httpx
def complete(self, prompt: str) -> str:
"""Отправить промпт в LLM и вернуть текст ответа.
Args:
prompt: Текст промпта (инструкция + данные для анализа)
Returns:
Текстовый ответ модели (обычно JSON-строка)
Raises:
httpx.HTTPError: при ошибке HTTP
KeyError: при неожиданном формате ответа
"""
# Ключ API: сначала LLM_KEY, затем LLM_API_KEY (для совместимости)
key = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
# URL и модель с значениями по умолчанию
url = os.environ.get("LLM_URL", "https://api.aillm.ru/v1/chat/completions")
model = os.environ.get("LLM_MODEL", "gpt-oss-120b")
r = self._httpx.post(
url,
json={
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 8000,
"temperature": 0.1,
},
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
},
timeout=120,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
+154
View File
@@ -0,0 +1,154 @@
"""
Оркестратор двухпроходной обфускации — класс TwoPassObfuscator.
Процесс:
1. Проход 1 (сбор): извлечь текст → regex-сканирование → LLM-сканирование
2. Проход 2 (замена): применить mapping ко всем файлам
3. Сборка: ZIP + mapping.csv
"""
import io
import os
import logging
from typing import Dict, List, Tuple, Callable, Optional
from . import extractor
from . import scanner
from . import replacer
from . import builder
log = logging.getLogger("drhider")
class TwoPassObfuscator:
"""Двухпроходный обфускатор документов.
Проход 1: собрать все сущности из всех файлов → глобальный словарь замен.
Проход 2: применить замены ко всем файлам → ZIP с результатом.
Согласованность: одна и та же сущность во всех файлах получает
одно и то же фиктивное значение.
Attributes:
_mapping: {оригинал: замена} — глобальный словарь
_sorted_keys: ключи mapping, отсортированные по длине (убывание)
_llm_client: опциональный LLM-клиент для NER
"""
def __init__(self, llm_client=None):
"""Инициализировать обфускатор.
Args:
llm_client: Объект с методом .complete(prompt) -> str.
Если None — LLM-сканирование не выполняется.
"""
self._mapping: Dict[str, str] = {}
self._sorted_keys: List[str] = []
self._counters: Dict[str, int] = {} # Глобальные счётчики токенов
self._llm_client = llm_client
# ═══════════════════════════════════════════════════════════════════
# Главная точка входа
# ═══════════════════════════════════════════════════════════════════
def obfuscate(
self, files: List[Tuple[str, bytes, str]]
) -> Tuple[bytes, str]:
"""Обфусцировать список файлов.
Все форматы → Markdown → замена → результат.
Args:
files: [(filename, content_bytes, content_type), ...]
Returns:
(zip_bytes, csv_string):
zip_bytes — ZIP-архив с обфусцированными .md файлами + mapping.csv
csv_string — содержимое mapping.csv как строка
"""
# ── Предобработка: распаковать ZIP ──
files = extractor.expand_zips(files)
try:
# ── Проход 1: сбор сущностей ──
# Извлекаем Markdown из каждого файла
all_texts: Dict[str, str] = {}
for fname, content, ctype in files:
text = extractor.extract_text(fname, content, ctype)
all_texts[fname] = text
# Regex-сканирование (быстрое, локальное)
if text and not text.startswith("[DOC binary"):
scanner.scan_regex(text, self._mapping, self._counters)
# LLM-сканирование (получает уже найденное regex'ом чтобы не дублировать)
if self._llm_client:
scanner.scan_llm_ner(all_texts, self._mapping, self._llm_client, self._counters)
# Предсортировать ключи один раз (по убыванию длины)
self._sorted_keys = sorted(
self._mapping.keys(), key=len, reverse=True
)
# ── Проход 2: замена сущностей ──
results: List[Tuple[str, bytes]] = []
for fname, content, ctype in files:
obf_content = content # По умолчанию — без изменений
if fname.endswith('.doc'):
# .doc — конвертируется через liberta, обрабатывается как .docx
md_text = all_texts.get(fname, '')
replaced = replacer.apply_replacements(
md_text, self._mapping, self._sorted_keys
)
md_name = os.path.splitext(fname)[0] + '.md'
obf_content = replaced.encode('utf-8')
fname = md_name
else:
# Все форматы → Markdown → замена в тексте
md_text = all_texts.get(fname, '')
replaced = replacer.apply_replacements(
md_text, self._mapping, self._sorted_keys
)
# Имя файла: заменить расширение на .md
md_name = os.path.splitext(fname)[0] + '.md'
obf_content = replaced.encode('utf-8')
fname = md_name
results.append((fname, obf_content))
# ── Сборка результата ──
csv_str = builder.build_mapping_csv(self._mapping)
zip_data = builder.build_zip(results, csv_str)
return zip_data, csv_str
finally:
# Очистка состояния (обфускатор может использоваться повторно)
self._mapping.clear()
self._sorted_keys.clear()
self._counters.clear()
# ═══════════════════════════════════════════════════════════════════════
# Удобная функция для быстрого вызова
# ═══════════════════════════════════════════════════════════════════════
def obfuscate_files(
files: List[Tuple[str, bytes, str]], llm_client=None
) -> Tuple[bytes, str]:
"""Обфусцировать список файлов — удобная функция.
Создаёт экземпляр TwoPassObfuscator и вызывает .obfuscate().
Args:
files: [(filename, content_bytes, content_type), ...]
llm_client: Опциональный LLM-клиент
Returns:
(zip_bytes, csv_string)
"""
obf = TwoPassObfuscator(llm_client=llm_client)
return obf.obfuscate(files)
+131
View File
@@ -0,0 +1,131 @@
"""
Проход 2: замена сущностей в документах.
Два метода:
1. apply_replacements — замена в Markdown/plain-тексте (основной)
2. replace_in_docx — замена в DOCX с сохранением форматирования
(для опционального DOCX-выхода, будет добавлен позже)
"""
import io
import re
from typing import Dict, List
def _md_tolerant_pattern(original: str) -> str:
r"""Паттерн, допускающий ** между частями строки (Markdown bold).
DOCX → Markdown: жирность добавляет ** вокруг слов.
Если ** попадает внутрь сущности (например, **ООО **"**НУБЕС**"),
точное совпадение не срабатывает.
Этот паттерн вставляет (?:\*{1,2})? между каждым словом/символом,
позволяя найти сущность даже с ** внутри.
Args:
original: Исходная строка сущности (например, 'ООО "НУБЕС"')
Returns:
Regex-паттерн, находящий ту же строку с ** между частями
"""
MD = r'(?:\*{1,2})?'
parts = re.findall(r'[А-ЯЁа-яёA-Za-z0-9]+|[^А-ЯЁа-яёA-Za-z0-9]', original)
if len(parts) <= 1:
return None # Одно слово — ** не может разорвать изнутри
return MD + MD.join(re.escape(p) for p in parts) + MD
def apply_replacements(text: str, mapping: Dict[str, str], sorted_keys: List[str]) -> str:
"""Применить все замены из словаря mapping к строке.
Ключи применяются в порядке убывания длины (sorted_keys).
Это гарантирует, что более длинные совпадения заменяются раньше
коротких (например, «ИНН 123456789012» до «ИНН 1234567890»).
Для сущностей, начинающихся и заканчивающихся на букву/цифру,
используются границы слова (\\b), чтобы избежать частичных замен.
Args:
text: Исходный текст
mapping: Словарь {оригинал: замена}
sorted_keys: Ключи mapping, отсортированные по длине (убывание)
Returns:
Текст с заменами
"""
result = text
for original in sorted_keys:
replacement = mapping[original]
# Если сущность обрамлена буквами/цифрами — используем границы слова
if original and original[0].isalnum() and original[-1].isalnum():
pattern = r'(?<!\w)' + re.escape(original) + r'(?!\w)'
else:
pattern = re.escape(original)
new_result = re.sub(pattern, replacement, result)
# Фоллбэк: Markdown-жирность могла разорвать строку
md_pattern = _md_tolerant_pattern(original)
if md_pattern is not None:
new_result = re.sub(md_pattern, replacement, new_result)
result = new_result
return result
def replace_in_docx(doc, mapping: Dict[str, str], sorted_keys: List[str]) -> bytes:
"""Заменить сущности в DOCX-документе с сохранением форматирования.
Алгоритм для каждого параграфа:
1. Склеиваем текст всех runs в одну строку
2. Применяем замены
3. Пишем результат в первый run, очищаем остальные
Это нужно потому что python-docx разбивает текст на runs
(например, mid-docx форматирование), и сущность может быть
разорвана между несколькими runs.
Args:
doc: Объект python-docx Document
mapping: Словарь замен
sorted_keys: Ключи mapping по убыванию длины
Returns:
Бинарное содержимое изменённого DOCX-файла
"""
# ── Обработка параграфов ──
for para in doc.paragraphs:
if not para.runs:
continue
# Склеиваем все runs в одну строку
full_text = "".join(run.text for run in para.runs)
replaced = apply_replacements(full_text, mapping, sorted_keys)
# Если были замены — пишем в первый run, очищаем остальные
if replaced != full_text:
para.runs[0].text = replaced
for run in para.runs[1:]:
run.text = ""
# ── Обработка таблиц ──
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
for para in cell.paragraphs:
if not para.runs:
continue
full_text = "".join(run.text for run in para.runs)
replaced = apply_replacements(full_text, mapping, sorted_keys)
if replaced != full_text:
para.runs[0].text = replaced
for run in para.runs[1:]:
run.text = ""
# Сохраняем в буфер
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
+179
View File
@@ -0,0 +1,179 @@
"""
Проход 1: обнаружение сущностей в тексте документов.
Два метода сканирования:
1. scan_regex — быстрое regex-обнаружение (телефоны, email, ИНН, счета, компании, ФИО)
2. scan_llm_ner — универсальное LLM-обнаружение (любые приватные данные)
Замена: гибридные токены — читаемый шаблон + уникальный номер.
Пример: Иванов_0001, ООО_Технология_0034, ул_Ленина_0015
"""
import re
import json
import random
import logging
from typing import Dict
from .config import (
ENTITY_PATTERNS, COMPANY_PATTERN, PERSON_PATTERN,
RU_SURNAMES, COMPANY_NOUNS, CITY_POOL, STREET_POOL,
)
log = logging.getLogger("drhider")
# ═══════════════════════════════════════════════════════════════════════════
# Гибридные токены: тип → (пул_шаблонов, префикс_для_счётчика)
# ═══════════════════════════════════════════════════════════════════════════
TYPE_POOLS = {
"person_name": (RU_SURNAMES, "person"),
"person": (RU_SURNAMES, "person"),
"company": (COMPANY_NOUNS, "company"),
"phone": (["+7_000_000"], "phone"),
"email": (["email"], "email"),
"address": (CITY_POOL, "address"),
"inn": (["ИНН"], "inn"),
"inn_ul": (["ИНН"], "inn"),
"inn_fl": (["ИНН"], "inn"),
"kpp": (["КПП"], "kpp"),
"ogrn": (["ОГРН"], "ogrn"),
"bik": (["БИК"], "bik"),
"passport": (["Паспорт"], "passport"),
"bank_account": (["Счёт"], "bank"),
"rs": (["РС"], "bank"),
"ks": (["КС"], "bank"),
"contract_number": (["Договор"], "contract"),
"other_id": (["ID"], "other"),
}
_FALLBACK_POOL = (["Данные"], "data")
def _next_token(entity_type: str, counters: Dict[str, int]) -> str:
"""Сгенерировать токен замены: случайный шаблон из пула + номер.
Одна и та же сущность в mapping получает один и тот же токен
(mapping dict гарантирует согласованность).
Разные сущности одного типа получают РАЗНЫЕ шаблоны из пула.
Args:
entity_type: Тип сущности (person_name, company, phone...)
counters: Глобальные счётчики {prefix_key: count}
Returns:
Строка вида "Иванов_0001", "ООО_Технология_0034", "[Адрес_0015]"
"""
pool, prefix_key = TYPE_POOLS.get(entity_type, _FALLBACK_POOL)
template = random.choice(pool)
key = f"{prefix_key}_{template}"
counters[key] = counters.get(key, 0) + 1
return f"{template}_{counters[key]:04d}"
log = logging.getLogger("drhider")
def scan_regex(text: str, mapping: Dict[str, str], counters: Dict[str, int]) -> None:
"""Сканировать текст regex-паттернами, заполнить словарь замен.
Замена: гибридные токены (шаблон + номер).
Args:
text: Текст документа для сканирования
mapping: Словарь замен (мутабельный, пополняется)
counters: Глобальные счётчики токенов (мутабельный)
"""
# ── Сущности по regex-паттернам ──
for entity_type, pattern in ENTITY_PATTERNS.items():
for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE):
original = match.group(0).strip()
if not original or original in mapping:
continue
mapping[original] = _next_token(entity_type, counters)
# ── Названия компаний ──
for match in COMPANY_PATTERN.finditer(text):
original = match.group(0).strip()
if not original or original in mapping:
continue
mapping[original] = _next_token("company", counters)
# ── ФИО ──
for match in PERSON_PATTERN.finditer(text):
original = match.group(0).strip()
if not original or original in mapping:
continue
mapping[original] = _next_token("person_name", counters)
def scan_llm_ner(all_texts: Dict[str, str], mapping: Dict[str, str],
llm_client, counters: Dict[str, int]) -> None:
"""Универсальное LLM-обнаружение приватных данных.
LLM САМА определяет типы. Замена — гибридные токены.
Args:
all_texts: {filename: text_content} — тексты всех файлов
mapping: Словарь замен (мутабельный, пополняется)
llm_client: Объект с методом .complete(prompt) -> str
counters: Глобальные счётчики токенов (мутабельный)
"""
# Формируем комбинированный текст: имя файла + первые 3000 символов
combined = "\n\n---FILE---\n\n".join(
f"FILE: {fname}\n{t[:3000]}" for fname, t in all_texts.items()
)
# ── Универсальный промпт: STEP 1 (Исполнитель) + STEP 2 (ПДн) ──
already_found = "\n".join(f"- {k}" for k in mapping.keys()) if mapping else "(none)"
prompt = (
"You are a PII (Personally Identifiable Information) detector for Russian legal documents.\n\n"
"Find ALL sensitive or private information that is NOT already captured below.\n\n"
"Already captured by regex (skip these exact strings):\n"
f"{already_found}\n\n"
"Return a JSON array. Each item must have:\n"
' - "type": MUST be one of: person_name, company, phone, email, address,\n'
' inn, kpp, ogrn, bik, passport, bank_account, contract_number, other_id\n'
' Use "other_id" only if nothing else fits. If unsure — do NOT include.\n'
' - "value": the EXACT string from the text (copy verbatim)\n\n'
"NOT PII — do NOT include these (legal terms, not private data):\n"
"- Roles: Исполнитель, Заказчик, Стороны, Сторона, Покупатель, Поставщик\n"
"- Titles: Генеральный директор, Директор, действующего на основании\n"
"- Legal terms: Услуги, Договор, Приложение, Спецификация, НДС, Устава\n\n"
"Rules:\n"
"- Copy value VERBATIM — same spaces, punctuation, case as in text\n"
"- Same value → include only once\n"
"- Return ONLY the JSON array, no other text, no markdown wrapping\n\n"
f"Text:\n{combined[:8000]}"
)
try:
# Отправляем запрос в LLM
raw = llm_client.complete(prompt)
# Чистим markdown-обёртку (если LLM вернула ```json ... ```)
raw = raw.strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1]
if raw.endswith("```"):
raw = raw[:-3]
entities = json.loads(raw)
# Добавляем найденные сущности в mapping
for ent in entities:
val = ent.get("value", "").strip()
if not val or val in mapping:
continue
# Тип — любой (LLM сама придумала)
ent_type = ent.get("type", "").strip().lower().replace(" ", "_")
mapping[val] = _next_token(ent_type, counters)
except Exception as e:
log.warning("LLM NER failed: %s", e)
-151
View File
@@ -1,151 +0,0 @@
#!/usr/bin/env python3
"""DrHider standalone server — NO DB dependency. Port 8767."""
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn, TCPServer
from urllib.parse import urlparse
import json, re, os, sys, cgi
DRHIDER_SERVER_VERSION = "1.3"
MAX_BODY = 200 * 1024 * 1024 # 200 MB
# ── Загрузка .env ────────────────────────────────────────────────────────
_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env")
if os.path.exists(_env_path):
with open(_env_path) as _f:
for _line in _f:
_line = _line.strip()
if _line and not _line.startswith("#") and "=" in _line:
_k, _v = _line.split("=", 1)
if _k not in os.environ:
os.environ[_k] = _v
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
class Handler(BaseHTTPRequestHandler):
def do_OPTIONS(self):
self.send_response(200)
self._send_cors()
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/health":
self._json({"ok": True, "server": "drhider", "version": DRHIDER_SERVER_VERSION})
else:
self.send_error(404)
def do_POST(self):
if self.path == "/api/drhider":
self._handle_drhider()
else:
self.send_error(404)
# ── DrHider ──────────────────────────────────────────────────────────
def _handle_drhider(self):
"""Обфускация: multipart files → ZIP."""
from drhider import obfuscate_files
length = int(self.headers.get("Content-Length", 0))
if length <= 0 or length > MAX_BODY:
self._json({"ok": False, "error": "Файл слишком большой"}, 413)
return
raw = self.rfile.read(length)
ctype = self.headers.get("Content-Type", "")
_, params = cgi.parse_header(ctype)
boundary = params.get("boundary", "")
if not boundary:
self._json({"ok": False, "error": "No boundary"}, 400)
return
parts = raw.split(b"--" + boundary.encode())
files = []
for part in parts:
if b"Content-Disposition" not in part:
continue
try:
hdr_end = part.index(b"\r\n\r\n")
except ValueError:
continue
hdrs = part[:hdr_end].decode("latin-1", errors="replace")
body = part[hdr_end + 4:]
if body.endswith(b"\r\n"):
body = body[:-2]
m = re.search(r'filename="([^"]*)"', hdrs)
if not m:
continue
name = os.path.basename(m.group(1))
try:
name = name.encode('latin-1').decode('utf-8')
except (UnicodeDecodeError, UnicodeEncodeError):
pass
if not name or ".." in name or "/" in name or "\\" in name:
continue
files.append((name, body, ""))
if not files:
self._json({"ok": False, "error": "Нет файлов"}, 400)
return
class _VMLLM:
def complete(self, prompt):
import httpx
key = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
r = httpx.post(
os.environ.get("LLM_URL", "https://api.aillm.ru/v1/chat/completions"),
json={"model": os.environ.get("LLM_MODEL", "gpt-oss-120b"),
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 8000, "temperature": 0.1},
headers={"Authorization": f"Bearer {key}",
"Content-Type": "application/json"},
timeout=120
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
try:
zip_data, _csv = obfuscate_files(files, llm_client=_VMLLM())
self.send_response(200)
self.send_header("Content-Type", "application/zip")
self.send_header("Content-Disposition", 'attachment; filename="drhider_output.zip"')
self.send_header("Content-Length", str(len(zip_data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(zip_data)
except Exception as e:
import traceback
traceback.print_exc()
self._json({"ok": False, "error": str(e)}, 500)
# ── Helpers ───────────────────────────────────────────────────────────
def _json(self, data, status=200):
self.send_response(status)
self._send_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode())
def _send_cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
def log_message(self, format, *args):
pass
if __name__ == "__main__":
TCPServer.allow_reuse_address = True
port = int(os.environ.get("PORT", "8767"))
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
print(f"DrHider server on :{port} (NO DB)")
try:
server.serve_forever()
except KeyboardInterrupt:
server.shutdown()
+1 -2
View File
@@ -1,6 +1,5 @@
flask
gunicorn
gevent
waitress
python-docx
pdfplumber
httpx
+56 -84
View File
@@ -1,96 +1,68 @@
"""
DrHider — Managed Flask приложение на платформе Штурвал.
Приложение создаётся фабрикой create_app().
Штурвал запускает Flask самостоятельно (без Dockerfile/gunicorn).
Роуты разнесены по blueprint'ам:
- main_bp: GET / — веб-интерфейс
- health_bp: GET /health — проверка живости
- api_bp: POST /api/drhider — обфускация документов
"""
import os
import io
import sys
import json
import zipfile
from flask import Flask, render_template, request, send_file, jsonify
from flask import Flask
# Добавляем корень в sys.path чтобы import drhider работал
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Добавляем корень проекта в sys.path для импорта пакета drhider
_sys_path_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root)
from drhider import obfuscate_files
# Версия приложения (меняется при изменениях)
VERSION = "0.0.31"
def create_app():
"""Создать и настроить Flask-приложение.
Штурвал запускает из директории site/:
from app import app
Поэтому routes/ доступен как обычный подпакет.
Returns:
Экземпляр Flask с зарегистрированными blueprint'ами.
"""
app = Flask(__name__)
VERSION = "1.0.0"
# Конфигурация
app.config["VERSION"] = VERSION
app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB
MAX_BODY = 200 * 1024 * 1024 # 200 MB
# Регистрируем blueprint'ы
from routes import register_routes
register_routes(app)
# Запрещаем кэширование — F5 всегда грузит свежую страницу
@app.after_request
def no_cache(response):
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
return app
class LLMClient:
"""LLM-клиент для drhider (обнаружение компаний/ФИО)."""
def __init__(self):
import httpx
self._httpx = httpx
def complete(self, prompt: str) -> str:
key = os.environ.get("LLM_KEY") or os.environ.get("LLM_API_KEY", "")
r = self._httpx.post(
os.environ.get("LLM_URL", "https://api.aillm.ru/v1/chat/completions"),
json={
"model": os.environ.get("LLM_MODEL", "gpt-oss-120b"),
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 8000,
"temperature": 0.1,
},
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
},
timeout=120,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
@app.route("/")
def index():
return render_template("index.html", version=VERSION)
@app.route("/health")
def health():
return jsonify({"ok": True, "version": VERSION})
@app.route("/api/drhider", methods=["POST"])
def api_drhider():
"""Обфускация: multipart/form-data с файлами → ZIP."""
uploaded = request.files.getlist("files")
if not uploaded:
return jsonify({"ok": False, "error": "Нет файлов"}), 400
files = []
for f in uploaded:
if f.filename:
files.append((f.filename, f.read(), f.mimetype or ""))
if not files:
return jsonify({"ok": False, "error": "Нет файлов"}), 400
try:
llm = LLMClient()
zip_data, _csv = obfuscate_files(files, llm_client=llm)
return send_file(
io.BytesIO(zip_data),
mimetype="application/zip",
as_attachment=True,
download_name="drhider_output.zip",
)
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({"ok": False, "error": str(e)}), 500
# Экземпляр приложения — точка входа для Штурвала
app = create_app()
# Штурвал запускает: cd site && python app.py
if __name__ == "__main__":
import subprocess
subprocess.run([
sys.executable, "-m", "gunicorn", "app:app",
"--bind", "0.0.0.0:5000",
"--worker-class", "gevent",
"--workers", "1",
"--worker-connections", "1000",
"--timeout", "300",
])
from waitress import serve
serve(app, host="0.0.0.0", port=5000, threads=8)
+14
View File
@@ -0,0 +1,14 @@
from .main_bp import main_bp
from .health_bp import health_bp
from .api_bp import api_bp
def register_routes(app):
"""Зарегистрировать все blueprint'ы на Flask-приложении.
Args:
app: Экземпляр Flask-приложения
"""
app.register_blueprint(main_bp)
app.register_blueprint(health_bp)
app.register_blueprint(api_bp)
+159
View File
@@ -0,0 +1,159 @@
"""
Blueprint: API DrHider.
Five endpoints:
- POST /api/upload — upload one file -> {session_id}
- GET /api/process_stream/<sid> — SSE: process files, per-file progress
- POST /api/process/<sid> — process all session files -> {status:"done"} (legacy)
- GET /api/download/<sid> — download ZIP (with timestamp name)
- GET /api/csv/<sid> — download CSV separately
"""
import io
import json
import zipfile
import traceback
from datetime import datetime, timedelta
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
from drhider import obfuscate_files, LLMClient
from drhider.builder import build_zip, build_mapping_csv
from session import (create_session, add_file, get_files, store_result,
get_result, store_csv, get_csv, cleanup, file_count)
api_bp = Blueprint("api", __name__, url_prefix="/api")
@api_bp.route("/upload", methods=["POST"])
def upload():
"""Upload one file to session."""
sid = request.form.get("session", "")
if not sid:
sid = create_session()
uploaded = request.files.getlist("files")
if not uploaded:
return jsonify({"ok": False, "error": "No file"}), 400
f = uploaded[0]
if not f.filename:
return jsonify({"ok": False, "error": "No filename"}), 400
ok = add_file(sid, f.filename, f.read())
if not ok:
return jsonify({"ok": False, "error": "Session not found"}), 404
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
@api_bp.route("/process_stream/<sid>", methods=["GET"])
def process_stream(sid):
"""SSE: process all session files one by one, streaming per-file progress."""
files = get_files(sid)
if files is None:
return jsonify({"ok": False, "error": "Session not found"}), 404
if not files:
return jsonify({"ok": False, "error": "No files"}), 400
total = len(files)
def generate():
llm = LLMClient()
all_results = []
all_mapping = {}
for idx, (fname, content) in enumerate(files):
# Отправляем: начали файл
try:
yield f"event: start\ndata: {json.dumps({'idx': idx, 'name': fname, 'total': total})}\n\n"
except GeneratorExit:
return # клиент отключился
zip_data, _csv = obfuscate_files([(fname, content, "")], llm_client=llm)
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
for name in zf.namelist():
if name == "mapping.csv":
csv_text = zf.read(name).decode("utf-8")
lines = csv_text.strip().split("\n")
if len(lines) > 1:
for line in lines[1:]:
parts = line.split(",", 2)
if len(parts) >= 3:
all_mapping[parts[1]] = parts[2]
elif not name.endswith("/"):
all_results.append((name, zf.read(name)))
# Отправляем: закончили файл (если клиент ушёл — освобождаем поток)
try:
yield f"event: done\ndata: {json.dumps({'idx': idx, 'name': fname, 'total': total})}\n\n"
except GeneratorExit:
return # клиент отключился, НЕ обрабатываем остальные файлы
csv_str = build_mapping_csv(all_mapping) if all_mapping else ""
final_zip = build_zip(all_results)
store_result(sid, final_zip)
if csv_str:
store_csv(sid, csv_str)
yield f"event: complete\ndata: {json.dumps({'total': len(all_results)})}\n\n"
return Response(
stream_with_context(generate()),
content_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
@api_bp.route("/process/<sid>", methods=["POST"])
def process(sid):
"""Process all session files one by one -> ZIP."""
files = get_files(sid)
if files is None:
return jsonify({"ok": False, "error": "Session not found"}), 404
if not files:
return jsonify({"ok": False, "error": "No files"}), 400
try:
llm = LLMClient()
all_results = []
all_mapping = {}
for fname, content in files:
zip_data, _csv = obfuscate_files([(fname, content, "")], llm_client=llm)
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
for name in zf.namelist():
if name == "mapping.csv":
csv_text = zf.read(name).decode("utf-8")
lines = csv_text.strip().split("\n")
if len(lines) > 1:
for line in lines[1:]:
parts = line.split(",", 2)
if len(parts) >= 3:
all_mapping[parts[1]] = parts[2]
elif not name.endswith("/"):
all_results.append((name, zf.read(name)))
csv_str = build_mapping_csv(all_mapping) if all_mapping else ""
final_zip = build_zip(all_results) # CSV — отдельно, не в ZIP
store_result(sid, final_zip)
if csv_str:
store_csv(sid, csv_str)
return jsonify({"ok": True, "status": "done", "files": len(all_results)})
except Exception as e:
traceback.print_exc()
return jsonify({"ok": False, "error": str(e)}), 500
@api_bp.route("/download/<sid>", methods=["GET"])
def download(sid):
"""Download result and cleanup session."""
zip_data = get_result(sid)
if zip_data is None:
return jsonify({"ok": False, "error": "Not found"}), 404
ts = (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d_%H-%M-%S")
return send_file(io.BytesIO(zip_data), mimetype="application/zip",
as_attachment=True, download_name=f"drhider_{ts}.zip")
@api_bp.route("/csv/<sid>", methods=["GET"])
def csv_download(sid):
"""Download CSV separately."""
csv_str = get_csv(sid)
if csv_str is None:
return jsonify({"ok": False, "error": "Not found"}), 404
ts = (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d_%H-%M-%S")
buf = io.BytesIO()
buf.write('\ufeff'.encode('utf-8') + csv_str.encode('utf-8'))
buf.seek(0)
return send_file(buf, mimetype="text/csv",
as_attachment=True, download_name=f"mapping_{ts}.csv")
+27
View File
@@ -0,0 +1,27 @@
"""
Blueprint: health-check (GET /health).
Используется платформой Штурвал для проверки живости приложения.
"""
from flask import Blueprint, jsonify, current_app
# ═══════════════════════════════════════════════════════════════════════════
# Blueprint: health check
# ═══════════════════════════════════════════════════════════════════════════
health_bp = Blueprint("health", __name__)
@health_bp.route("/health")
def health():
"""Эндпоинт проверки живости.
Возвращает JSON с версией приложения.
Используется платформой для readiness/liveness probes.
Returns:
{"ok": true, "version": "X.Y.Z"}
"""
version = current_app.config.get("VERSION", "0.0.0")
return jsonify({"ok": True, "version": version})
+23
View File
@@ -0,0 +1,23 @@
"""
Blueprint: главная страница (GET /).
Отдаёт HTML-интерфейс DrHider.
"""
from flask import Blueprint, render_template, current_app
# ═══════════════════════════════════════════════════════════════════════════
# Blueprint: главная страница
# ═══════════════════════════════════════════════════════════════════════════
main_bp = Blueprint("main", __name__)
@main_bp.route("/")
def index():
"""Главная страница — веб-интерфейс DrHider.
Передаёт в шаблон текущую версию приложения.
"""
version = current_app.config.get("VERSION", "0.0.0")
return render_template("index.html", version=version)
+170
View File
@@ -0,0 +1,170 @@
"""
Хранилище сессий в памяти с автоочисткой по TTL.
Использование:
sid = create_session()
add_file(sid, filename, content_bytes)
files = get_files(sid)
store_result(sid, zip_bytes)
zip_data = get_result(sid)
cleanup(sid)
"""
import uuid
import threading
import time
from typing import Dict, List, Tuple, Optional
# ═══════════════════════════════════════════════════════════════════════════
# Данные сессии
# ═══════════════════════════════════════════════════════════════════════════
TTL_SECONDS = 30 * 60 # 30 минут
_sessions: Dict[str, dict] = {}
_lock = threading.Lock()
def _start_timer(sid: str):
"""Запустить таймер автоочистки сессии через TTL."""
def _clean():
with _lock:
_sessions.pop(sid, None)
timer = threading.Timer(TTL_SECONDS, _clean)
timer.daemon = True
timer.start()
return timer
# ═══════════════════════════════════════════════════════════════════════════
# API
# ═══════════════════════════════════════════════════════════════════════════
def create_session() -> str:
"""Создать новую сессию.
Returns:
Уникальный идентификатор сессии (UUID).
"""
sid = uuid.uuid4().hex[:12]
with _lock:
_sessions[sid] = {
"files": [],
"result": None,
"timer": _start_timer(sid),
}
return sid
def add_file(sid: str, filename: str, content: bytes) -> bool:
"""Добавить файл в сессию.
Args:
sid: Идентификатор сессии
filename: Имя файла
content: Бинарное содержимое
Returns:
True если сессия существует, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["files"].append((filename, content))
return True
def get_files(sid: str) -> Optional[List[Tuple[str, bytes]]]:
"""Получить все файлы сессии.
Args:
sid: Идентификатор сессии
Returns:
[(filename, content), ...] или None если сессия не найдена.
"""
with _lock:
s = _sessions.get(sid)
return list(s["files"]) if s else None
def file_count(sid: str) -> int:
"""Количество файлов в сессии."""
with _lock:
s = _sessions.get(sid)
return len(s["files"]) if s else 0
def store_result(sid: str, zip_data: bytes) -> bool:
"""Сохранить результат обработки.
Args:
sid: Идентификатор сессии
zip_data: ZIP-архив с результатом
Returns:
True если сессия существует, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["result"] = zip_data
return True
def get_result(sid: str) -> Optional[bytes]:
"""Получить результат обработки.
Args:
sid: Идентификатор сессии
Returns:
ZIP-архив или None если сессия не найдена/результат не готов.
"""
with _lock:
s = _sessions.get(sid)
return s["result"] if s else None
def cleanup(sid: str):
"""Удалить сессию."""
with _lock:
s = _sessions.pop(sid, None)
if s and s.get("timer"):
s["timer"].cancel()
def store_csv(sid: str, csv_str: str) -> bool:
"""Сохранить CSV с таблицей замен.
Args:
sid: Идентификатор сессии
csv_str: Строка CSV
Returns:
True если сессия существует, False если нет.
"""
with _lock:
s = _sessions.get(sid)
if not s:
return False
s["csv"] = csv_str
return True
def get_csv(sid: str) -> Optional[str]:
"""Получить CSV с таблицей замен.
Args:
sid: Идентификатор сессии
Returns:
Строка CSV или None если нет.
"""
with _lock:
s = _sessions.get(sid)
return s.get("csv") if s else None
+23 -64
View File
@@ -1,66 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="120"
height="120"
id="svg2"
version="1.1"
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
sodipodi:docname="favicon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="5.6"
inkscape:cx="91.428571"
inkscape:cy="54.910714"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1027"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:pagecheckerboard="0"
width="120px" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-80.000001,-1018.2599)">
<path
style="fill:#004d00;stroke-width:0.100353;fill-opacity:1"
d="m 144.12346,1127.3527 c -14.7384,-15.4935 -17.30029,-23.203 -14.91709,-44.8912 1.78418,-16.2372 1.43961,-18.7434 -3.2949,-23.9623 -4.60141,-5.0725 -4.72472,-5.8285 -0.95017,-5.8285 5.32989,0 5.67331,-6.0284 0.58406,-10.2519 -4.3155,-3.5819 -10.08626,-19.468 -8.64375,-23.7956 1.09412,-3.2824 21.29169,27.0158 23.93661,35.9068 0.94001,3.1601 8.83542,12.1943 17.54536,20.0767 11.55761,10.4589 14.47966,14.331 10.81518,14.331 -2.7616,0 -9.48553,-3.0338 -14.94204,-6.7417 -6.36215,-4.323 -10.81609,-5.8461 -12.41642,-4.2461 -1.60033,1.6005 3.29311,7.7342 13.64167,17.0989 8.87547,8.0322 15.48529,15.2553 14.68853,16.052 -0.79676,0.7967 -7.93147,-2.6984 -15.85488,-7.7667 -16.57177,-10.6009 -17.2489,-10.7849 -17.23587,-4.6824 0.0132,5.7346 7.77953,18.5635 16.5486,27.3327 3.71587,3.7157 6.75613,7.9746 6.75613,9.4643 0,5.5255 -6.27497,2.4016 -16.26102,-8.096 z"
id="path3974"
inkscape:connector-curvature="0" />
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 8221.93 1821.07"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#001C34}
]]>
</style>
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_2283355517888">
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 8221.93 1821.07"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#001C34}
]]>
</style>
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_2283355517888">
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+269 -120
View File
@@ -3,207 +3,356 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>DrHider — обфускация документов</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
:root {
--bg: #f5f5f5;
--brand-primary: #2563eb;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--bg: #f9fafb;
--card: #ffffff;
--border: #e0e0e0;
--text: #1a1a1a;
--muted: #888;
--brand: #2563eb;
--muted: #6b7280;
--red: #ef4444;
--green: #22c55e;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font: 14px system-ui, sans-serif; color: var(--text); background: var(--bg); min-height: 100vh; }
.topbar {
background: var(--card); border-bottom: 1px solid var(--border);
padding: 0 24px; height: 52px; display: flex; align-items: center; gap: 12px;
font-weight: 600; font-size: 15px;
background: var(--card); border-bottom: 1px solid var(--brand-gray);
padding: 0 16px; height: 48px; display: flex; align-items: center; gap: 10px;
}
.content { max-width: 780px; margin: 28px auto; padding: 0 16px; }
.topbar img { height: 18px; }
.topbar .title { font-weight: 600; font-size: 14px; }
.topbar .ver { font-weight: 400; color: var(--muted); font-size: 12px; }
.topbar .help { margin-left: auto; font-size: 12px; color: var(--muted); cursor: pointer; text-decoration: underline; }
.topbar .help:hover { color: var(--text); }
.content { max-width: 820px; margin: 20px auto; padding: 0 12px; }
.card {
background: var(--card); border-radius: 10px;
border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,.05);
overflow: hidden;
background: var(--card); border-radius: 12px;
border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.card-header {
background: #fafafa; padding: 10px 16px;
font-weight: 600; font-size: 14px;
border-bottom: 1px solid var(--border);
}
.card-body { padding: 16px; }
.file-input-wrap { margin-bottom: 12px; }
.file-input-wrap input[type="file"] { width: 100%; font-size: 14px; }
.table-wrap {
border: 1px solid var(--border); border-radius: 6px;
overflow-y: auto; max-height: 60vh;
background: var(--brand-grey-light); padding: 10px 12px;
font-weight: 600; font-size: 14px; border-bottom: 1px solid var(--brand-gray);
}
.card-body { padding: 12px; }
.sub { font-size: 12px; color: var(--muted); margin-bottom: 10px; line-height: 1.5; }
.file-input-wrap { margin-bottom: 10px; }
.file-input-wrap input[type="file"] { width: 100%; font-size: 13px; }
.table-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
background: #fafafa; text-transform: uppercase; padding: 6px 10px;
border-right: 1px solid var(--border); text-align: left;
font-weight: 600; font-size: 11px; color: var(--muted);
background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 8px;
border-right: 1px solid var(--brand-gray); text-align: left;
font-weight: 600; font-size: 10px; color: var(--muted);
}
td {
padding: 6px 10px; border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 5px 8px; border-right: 1px solid var(--brand-gray);
border-bottom: 1px solid var(--brand-gray);
}
tr:hover td { background: rgba(37,99,235,.03); }
.name-cell { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.num-cell { text-align: right; white-space: nowrap; }
.empty-row td { color: var(--muted); text-align: center; padding: 24px; }
.empty-row td { color: var(--muted); text-align: center; padding: 20px; }
.remove-btn {
cursor: pointer; color: #f87171; background: none; border: none;
padding: 2px 6px; font-size: 15px; line-height: 1;
padding: 2px 4px; font-size: 14px; line-height: 1;
}
.remove-btn:hover { color: var(--red); }
.footer-bar {
margin-top: 12px; display: flex; justify-content: space-between; align-items: center;
font-size: 13px; color: var(--muted);
margin-top: 10px; display: flex; justify-content: space-between; align-items: center;
font-size: 12px; color: var(--muted);
}
.btn {
height: 34px; border-radius: 6px; padding: 0 16px; font-size: 13px;
font-family: inherit; cursor: pointer; border: 1px solid var(--border);
height: 32px; border-radius: 6px; padding: 0 12px; font-size: 13px;
font-family: inherit; cursor: pointer; border: 1px solid var(--brand-gray);
background: var(--card); color: var(--text);
display: inline-flex; align-items: center; gap: 6px;
display: inline-flex; align-items: center; gap: 5px;
}
.btn-primary { background: var(--brand); border-color: var(--brand); color: #fff; }
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { opacity: .9; }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.status { margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 13px; display: none; }
.status.success { background: #dcfce7; color: #166534; display: block; }
.status.error { background: #fef2f2; color: #991b1b; display: block; }
.status.info { background: #eff6ff; color: #1e40af; display: block; }
.status { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 13px; display: none; }
.status.progress { background: #eff6ff; border: 1px solid var(--brand-primary); color: var(--brand-primary); display: block; }
.status.done { background: #f0fdf4; border: 1px solid var(--green); color: #16a34a; display: block; }
.status.error { background: #fef2f2; border: 1px solid var(--red); color: #991b1b; display: block; }
.dl-btns { display: none; gap: 8px; margin-top: 8px; }
.dl-btns.show { display: flex; }
</style>
</head>
<body>
<div class="topbar">
🛡️ DrHider — обфускация документов
<span style="font-weight:400;color:var(--muted);font-size:12px;margin-left:auto;">v{{ version }}</span>
<img src="/static/logo.svg" alt="Nubes">
<span class="title">DrHider</span>
<span class="ver">v{{ version }}</span>
<span class="help" onclick="document.getElementById('helpModal').style.display='flex'">?</span>
</div>
<div class="content">
<div class="card">
<div class="card-header">Выбор файлов</div>
<div class="card-header">Обфускация документов</div>
<div class="card-body">
<p style="color:var(--muted);margin-bottom:12px;font-size:13px;">
Загрузите документы (.docx, .xlsx, .pptx, .pdf, .txt).<br>
DrHider заменит все персональные данные, реквизиты компаний, телефоны, email на фиктивные значения.
<p class="sub">
Загрузите документы (.docx, .pdf, .txt, .zip) — получите ZIP с обезличенными копиями.<br>
CSV с таблицей замен скачивается отдельно.
</p>
<div class="file-input-wrap">
<input type="file" id="fileInput" multiple accept=".docx,.xlsx,.pptx,.pdf,.txt,.doc,.xls,.ppt">
<input type="file" id="fileInput" multiple accept=".doc,.docx,.pdf,.txt,.zip">
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th style="width:90px;">Размер</th>
<th style="width:40px;"></th>
<th style="width:80px;">Размер</th>
<th style="width:80px;">Статус</th>
<th style="width:32px;"></th>
</tr>
</thead>
<tbody id="fileList">
<tr class="empty-row"><td colspan="3">Нет выбранных файлов</td></tr>
<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>
</tbody>
</table>
</div>
<div class="footer-bar">
<span id="fileCount">0 файлов</span>
<button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">
🛡️ Обфусцировать
</button>
<button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">🛡️ Обфусцировать</button>
</div>
<div class="status" id="status"></div>
<div class="dl-btns" id="dlBtns">
<button class="btn btn-primary" id="dlZip" onclick="downloadZip()">📦 Скачать ZIP</button>
<button class="btn" id="dlCsv" onclick="downloadCsv()">📋 Скачать CSV</button>
</div>
</div>
</div>
</div>
<script>
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const fileCount = document.getElementById('fileCount');
const uploadBtn = document.getElementById('uploadBtn');
const status = document.getElementById('status');
let selectedFiles = [];
const fi = document.getElementById('fileInput');
const fl = document.getElementById('fileList');
const fc = document.getElementById('fileCount');
const ub = document.getElementById('uploadBtn');
const st = document.getElementById('status');
const db = document.getElementById('dlBtns');
let sf = [];
let currentSid = '';
let activeES = null; // активный EventSource
let activeXHR = null; // активный XHR
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
function resetAll() {
if (activeES) { activeES.close(); activeES = null; }
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
currentSid = '';
sf = [];
fi.value = '';
rr();
st.className = '';
st.textContent = '';
db.classList.remove('show');
ub.disabled = false;
}
function renderFiles() {
if (selectedFiles.length === 0) {
fileList.innerHTML = '<tr class="empty-row"><td colspan="3">Нет выбранных файлов</td></tr>';
} else {
fileList.innerHTML = selectedFiles.map((f, i) =>
'<tr>' +
'<td class="name-cell">' + f.name + '</td>' +
'<td class="num-cell">' + formatSize(f.size) + '</td>' +
'<td><button class="remove-btn" onclick="removeFile(' + i + ')">✕</button></td>' +
'</tr>'
).join('');
}
fileCount.textContent = selectedFiles.length + ' файлов';
uploadBtn.disabled = selectedFiles.length === 0;
// При F5 / закрытии вкладки — обрубить всё
window.addEventListener('beforeunload', () => resetAll());
function fs(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; }
function rr() {
if (sf.length === 0) { fl.innerHTML = '<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>'; }
else { fl.innerHTML = sf.map((f, i) => '<tr id="row-' + i + '"><td class="name-cell">' + f.name + '</td><td class="num-cell">' + fs(f.size) + '</td><td class="num-cell" id="st-' + i + '" style="font-size:12px;">—</td><td><button class="remove-btn" onclick="rm(' + i + ')">✕</button></td></tr>').join(''); }
fc.textContent = sf.length + ' файлов';
ub.disabled = sf.length === 0;
}
function removeFile(i) {
selectedFiles.splice(i, 1);
// Пересоздаём FileList через DataTransfer
const dt = new DataTransfer();
selectedFiles.forEach(f => dt.items.add(f));
fileInput.files = dt.files;
renderFiles();
}
function rm(i) { sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); }
fileInput.addEventListener('change', () => {
selectedFiles = Array.from(fileInput.files);
renderFiles();
window.addEventListener('load', () => { sf = []; fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); });
fi.addEventListener('change', () => {
const incoming = Array.from(fi.files);
// Добавляем к существующим, исключая дубликаты по имени+размеру
const seen = new Set(sf.map(f => f.name + '|' + f.size));
for (const f of incoming) {
if (!seen.has(f.name + '|' + f.size)) sf.push(f);
}
// Обновить file input — все файлы из sf
const d = new DataTransfer();
sf.forEach(f => d.items.add(f));
fi.files = d.files;
rr();
});
function uploadFiles() {
if (selectedFiles.length === 0) return;
status.className = 'status info';
status.textContent = 'Обфускация...';
uploadBtn.disabled = true;
function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }
const formData = new FormData();
selectedFiles.forEach(f => formData.append('files', f));
async function uploadFiles() {
if (sf.length === 0) return;
// Сохранить список до очистки
const files = sf.slice();
// Обрубить всё что могло остаться от предыдущего раза
if (activeES) { activeES.close(); activeES = null; }
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
currentSid = '';
st.className = '';
st.textContent = '';
db.classList.remove('show');
ub.disabled = true;
const total = files.length;
fetch('/api/drhider', { method: 'POST', body: formData })
.then(res => {
if (!res.ok) return res.json().then(e => { throw new Error(e.error || 'Ошибка сервера'); });
const disp = res.headers.get('Content-Disposition') || '';
const nameMatch = disp.match(/filename="?(.+?)"?$/);
const filename = nameMatch ? nameMatch[1] : 'drhider_output.zip';
return res.blob().then(blob => ({ blob, filename }));
// Фаза 1: загрузка (XMLHttpRequest — прогресс в строке)
for (let i = 0; i < total; i++) {
const f = files[i], n = i + 1;
st.className = 'status progress';
st.textContent = 'Загрузка ' + n + '/' + total + ': ' + f.name;
try {
const t0 = performance.now();
await new Promise((resolve, reject) => {
const fd = new FormData();
fd.append('files', f, f.name);
if (currentSid) fd.append('session', currentSid);
const url = '/api/upload?_=' + Date.now();
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.timeout = 30000;
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const pct = Math.round(e.loaded / e.total * 100);
ss(i, '<span style="color:#2563eb">⏳ ' + pct + '%</span>');
}
};
xhr.onload = function() {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
if (data.ok) {
const elapsed = (performance.now() - t0) / 1000;
const speed = f.size / elapsed;
ss(i, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>');
currentSid = data.session;
resolve();
} else {
reject(new Error(data.error));
}
} else {
reject(new Error('HTTP ' + xhr.status));
}
};
xhr.onerror = function() { reject(new Error('Сеть')); };
xhr.ontimeout = function() { reject(new Error('Таймаут 30с')); };
activeXHR = xhr;
xhr.send(fd);
});
} catch (err) {
ss(i, '<span style="color:#ef4444">✗</span>');
st.className = 'status error';
st.textContent = 'Ошибка загрузки: ' + err.message;
ub.disabled = false;
return;
}
}
// Фаза 2: обработка (SSE — прогресс по каждому файлу)
for (let i = 0; i < total; i++) ss(i, '<span style="color:#2563eb">⏳</span>');
const fileTimers = {}; // idx -> performance.now()
const fileIntervals = {}; // idx -> setInterval id
st.className = 'status progress';
st.textContent = 'Обработка...';
const t0 = performance.now();
const ptimer = setInterval(() => {
const sec = Math.round((performance.now() - t0) / 1000);
st.textContent = 'Обработка... ' + sec + 'с';
}, 1000);
try {
await new Promise((resolve, reject) => {
activeES = new EventSource('/api/process_stream/' + currentSid);
activeES.addEventListener('start', function(e) {
const d = JSON.parse(e.data);
fileTimers[d.idx] = performance.now();
fileIntervals[d.idx] = setInterval(function() {
const elapsed = ((performance.now() - fileTimers[d.idx]) / 1000).toFixed(1);
ss(d.idx, '<span style="color:#2563eb">⏳ ' + elapsed + 'с</span>');
}, 200);
});
activeES.addEventListener('done', function(e) {
const d = JSON.parse(e.data);
clearInterval(fileIntervals[d.idx]);
const elapsed = ((performance.now() - (fileTimers[d.idx] || performance.now())) / 1000).toFixed(1);
ss(d.idx, '<span style="color:#22c55e">✓ ' + elapsed + 'с</span>');
});
activeES.addEventListener('complete', function(e) {
activeES.close();
activeES = null;
clearInterval(ptimer);
const d = JSON.parse(e.data);
st.className = 'status done';
st.textContent = '✅ Обработано ' + d.total + ' файлов за ' + ((performance.now() - t0) / 1000).toFixed(1) + 'с';
db.classList.add('show');
resolve();
});
activeES.onerror = function() {
activeES.close();
activeES = null;
clearInterval(ptimer);
reject(new Error('SSE connection failed'));
};
});
} catch (err) {
clearInterval(ptimer);
st.className = 'status error';
st.textContent = 'Ошибка: ' + err.message;
}
ub.disabled = false;
}
function downloadZip() {
if (!currentSid) return;
fetch('/api/download/' + currentSid)
.then(r => {
const disp = r.headers.get('Content-Disposition');
const m = disp && disp.match(/filename="?(.+?)"?$/);
return Promise.all([r.blob(), m ? m[1] : 'drhider.zip']);
})
.then(({ blob, filename }) => {
status.className = 'status success';
status.textContent = 'Готово! Скачивание началось.';
const url = URL.createObjectURL(blob);
.then(([blob, fname]) => {
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.href = URL.createObjectURL(blob);
a.download = fname;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
uploadBtn.disabled = false;
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
});
}
function downloadCsv() {
if (!currentSid) return;
fetch('/api/csv/' + currentSid)
.then(r => {
const disp = r.headers.get('Content-Disposition');
const m = disp && disp.match(/filename="?(.+?)"?$/);
return Promise.all([r.blob(), m ? m[1] : 'mapping.csv']);
})
.catch(err => {
status.className = 'status error';
status.textContent = 'Ошибка: ' + err.message;
uploadBtn.disabled = false;
.then(([blob, fname]) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fname;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
});
}
</script>
<div id="helpModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center" onclick="this.style.display='none'">
<div style="background:var(--card);border-radius:12px;padding:24px;max-width:420px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">
<h3 style="margin-bottom:12px">DrHider — обфускация документов</h3>
<p style="font-size:13px;color:var(--muted);line-height:1.6">
Загрузите документы (.docx, .pdf, .txt, .zip) — получите ZIP-архив с обезличенными копиями.
Персональные данные, реквизиты компаний, телефоны, email и другие приватные сведения заменяются на обезличенные коды.
CSV-файл с таблицей соответствия оригинал→замена скачивается отдельно.
</p>
<button class="btn" style="margin-top:12px;width:100%" onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button>
</div>
</div>
</body>
</html>
+133
View File
@@ -0,0 +1,133 @@
"""
Тесты builder.py — создание ZIP и mapping.csv.
Запуск: python3 tests/test_builder.py
"""
import io
import zipfile
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from drhider.builder import build_zip, build_mapping_csv
passed = 0
failed = 0
def check(name, condition):
global passed, failed
if condition:
passed += 1
print(f"{name}")
else:
failed += 1
print(f"{name}")
# ═══════════════════════════════════════════════════════════════
# Test build_zip
# ═══════════════════════════════════════════════════════════════
print("\n=== build_zip ===")
# 1. Базовый ZIP с одним файлом
zip_data = build_zip([("test.md", b"content")])
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
names = zf.namelist()
check("один файл в ZIP", len(names) == 1)
check("имя test.md", "test.md" in names)
check("контент верный", zf.read("test.md") == b"content")
# 2. ZIP с несколькими файлами
zip_data = build_zip([
("договор_obfuscated.md", "# Договор\nТекст".encode("utf-8")),
("спецификация_obfuscated.md", "| Услуга | Цена |".encode("utf-8")),
])
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
names = zf.namelist()
check("два файла", len(names) == 2)
check("mapping.csv нет", "mapping.csv" not in names)
check("договор читается", "Договор" in zf.read("договор_obfuscated.md").decode("utf-8"))
# 3. ZIP с mapping.csv
zip_data = build_zip(
[("file.md", b"data")],
mapping_csv="тип,оригинал,замена\nperson,Иванов,Иванов_0001\n"
)
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
names = zf.namelist()
check("mapping.csv есть", "mapping.csv" in names)
csv_content = zf.read("mapping.csv").decode("utf-8")
check("mapping с BOM", csv_content.startswith('\ufeff'))
check("данные в mapping", "Иванов_0001" in csv_content)
# 4. ZIP с кириллицей в именах
zip_data = build_zip([("договор_№123.md", "текст".encode("utf-8"))])
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
check("кириллическое имя читается", "договор_№123.md" in zf.namelist())
# 5. ZIP с бинарными данными
binary = bytes(range(256))
zip_data = build_zip([("binary.bin", binary)])
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
check("бинарный файл сохранён", zf.read("binary.bin") == binary)
# 6. Пустой mapping
zip_data = build_zip([("f.md", b"x")], mapping_csv="")
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
check("пустой mapping не добавляется", "mapping.csv" not in zf.namelist())
# ═══════════════════════════════════════════════════════════════
# Test build_mapping_csv
# ═══════════════════════════════════════════════════════════════
print("\n=== build_mapping_csv ===")
# 1. Простой mapping
mapping = {"Иванов И.И.": "Иванов_0001"}
csv_str = build_mapping_csv(mapping)
check("заголовок есть", csv_str.startswith("тип_данных,оригинал,замена"))
check("данные есть", "Иванов_0001" in csv_str)
# 2. Пустой mapping
csv_str = build_mapping_csv({})
lines = csv_str.strip().split("\n")
check("пустой — только заголовок", len(lines) == 1)
# 3. Сортировка
mapping = {"Б": "Б_0001", "А": "А_0001"}
csv_str = build_mapping_csv(mapping)
check("сортировка А перед Б", csv_str.index("А_0001") < csv_str.index("Б_0001"))
# 4. Кавычки в значениях
mapping = {'ООО "Ромашка"': "ООО_Технология_0001"}
csv_str = build_mapping_csv(mapping)
check("кавычки экранированы", 'ООО "Ромашка"' in csv_str or 'ООО ""Ромашка""' in csv_str)
# ═══════════════════════════════════════════════════════════════
# Test: интеграция build_zip + build_mapping_csv
# ═══════════════════════════════════════════════════════════════
print("\n=== build_zip + build_mapping_csv ===")
mapping = {"Иванов И.И.": "Иванов_0001", "ООО Ромашка": "ООО_Технология_0001"}
csv = build_mapping_csv(mapping)
zip_data = build_zip([("doc.md", "# Договор с Иванов_0001".encode("utf-8"))], mapping_csv=csv)
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
check("два файла в ZIP", len(zf.namelist()) == 2)
doc = zf.read("doc.md").decode("utf-8")
check("doc с заменой", "Иванов_0001" in doc)
csv_content = zf.read("mapping.csv").decode("utf-8")
check("CSV с обеими заменами", "Иванов_0001" in csv_content and "ООО_Технология_0001" in csv_content)
# ═══════════════════════════════════════════════════════════════
print(f"\n{'='*50}")
print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}")
if failed > 0:
print("❌ ТЕСТЫ ПРОВАЛЕНЫ")
sys.exit(1)
else:
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
+137
View File
@@ -0,0 +1,137 @@
"""
Тесты extractor.py — извлечение текста, ZIP-распаковка.
Запуск: python3 tests/test_extractor.py
"""
import io
import zipfile
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from drhider.extractor import extract_text, expand_zips, docx_to_markdown, pdf_to_markdown
passed = 0
failed = 0
def check(name, condition):
global passed, failed
if condition:
passed += 1
print(f"{name}")
else:
failed += 1
print(f"{name}")
# ═══════════════════════════════════════════════════════════════
# extract_text
# ═══════════════════════════════════════════════════════════════
print("\n=== extract_text ===")
# 1. .txt
text = extract_text("test.txt", "Просто текст".encode("utf-8"))
check(".txt — текст извлечён", text == "Просто текст")
# 2. .txt с кириллицей
text = extract_text("документ.txt", "Привет мир".encode("utf-8"))
check(".txt — кириллица", "Привет" in text)
# 3. .doc (бинарный — пропускается)
text = extract_text("старый.doc", b"\xD0\xCF\x11\xE0\x00" * 10)
check(".doc — заглушка", "not supported" in text.lower() or "binary" in text.lower())
# 4. Неизвестное расширение → UTF-8
text = extract_text("file.xyz", "hello".encode("utf-8"))
check(".xyz — как текст", text == "hello")
# ═══════════════════════════════════════════════════════════════
# expand_zips
# ═══════════════════════════════════════════════════════════════
print("\n=== expand_zips ===")
# 1. Не-ZIP файлы проходят без изменений
files = [("test.txt", b"content", "text/plain")]
result = expand_zips(files)
check("не-ZIP — без изменений", len(result) == 1 and result[0][0] == "test.txt")
# 2. ZIP распаковывается
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr("inner.txt", b"hello")
result = expand_zips([("archive.zip", buf.getvalue(), "application/zip")])
check("ZIP — распакован", len(result) == 1 and result[0][0] == "inner.txt")
check("ZIP — контент верный", result[0][1] == b"hello")
# 3. ZIP с несколькими файлами
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr("a.txt", b"aaa")
zf.writestr("b.txt", b"bbb")
result = expand_zips([("multi.zip", buf.getvalue(), "")])
check("ZIP — два файла", len(result) == 2)
# 4. Path traversal защита
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr("../../etc/passwd", b"bad")
result = expand_zips([("evil.zip", buf.getvalue(), "")])
check("path traversal — заблокирован", all("/" not in r[0] and ".." not in r[0] for r in result))
# 5. Директории пропускаются
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr("dir/", b"")
zf.writestr("dir/file.txt", b"ok")
result = expand_zips([("dirs.zip", buf.getvalue(), "")])
check("директории — пропущены", len(result) == 1)
# ═══════════════════════════════════════════════════════════════
# docx_to_markdown
# ═══════════════════════════════════════════════════════════════
print("\n=== docx_to_markdown ===")
try:
from docx import Document
# Создаём тестовый DOCX
doc = Document()
doc.add_heading("Заголовок", level=1)
p = doc.add_paragraph("Обычный текст")
p.add_run(" жирный").bold = True
doc.add_paragraph("")
# Таблица
table = doc.add_table(rows=2, cols=2)
table.rows[0].cells[0].text = "A"
table.rows[0].cells[1].text = "B"
table.rows[1].cells[0].text = "1"
table.rows[1].cells[1].text = "2"
buf = io.BytesIO()
doc.save(buf)
md = docx_to_markdown(buf.getvalue())
check("DOCX — заголовок #", "# Заголовок" in md)
check("DOCX — жирный **", "жирный**" in md and "**" in md)
check("DOCX — таблица", "| A | B |" in md)
check("DOCX — разделитель таблицы", "---|---|" in md)
check("DOCX — данные таблицы", "| 1 | 2 |" in md)
except ImportError:
print(" ⚠️ python-docx не установлен — тесты DOCX пропущены")
# ═══════════════════════════════════════════════════════════════
# Итог
# ═══════════════════════════════════════════════════════════════
print(f"\n{'='*50}")
print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}")
if failed > 0:
print("❌ ТЕСТЫ ПРОВАЛЕНЫ")
sys.exit(1)
else:
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
+102
View File
@@ -0,0 +1,102 @@
"""
Тесты replacer.py — apply_replacements.
Запуск: python3 tests/test_replacer.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from drhider.replacer import apply_replacements
passed = 0
failed = 0
def check(name, condition):
global passed, failed
if condition:
passed += 1
print(f"{name}")
else:
failed += 1
print(f"{name}")
# ═══════════════════════════════════════════════════════════════
# apply_replacements
# ═══════════════════════════════════════════════════════════════
print("\n=== apply_replacements ===")
# 1. Простая замена
text = "Директор Иванов И.И. подписал"
mapping = {"Иванов И.И.": "Иванов_0001"}
keys = sorted(mapping.keys(), key=len, reverse=True)
result = apply_replacements(text, mapping, keys)
check("простая замена", result == "Директор Иванов_0001 подписал")
# 2. Длинное before короткого
text = "ИНН 123456789012 и ИНН 1234567890"
mapping = {"ИНН 123456789012": "ИНН_0001", "ИНН 1234567890": "ИНН_0002"}
keys = sorted(mapping.keys(), key=len, reverse=True)
result = apply_replacements(text, mapping, keys)
check("длинное раньше короткого", "ИНН_0001" in result and "ИНН_0002" in result)
check("12-значный не задет 10-значным", result == "ИНН_0001 и ИНН_0002")
# 3. Границы слова
text = "Email: test@mail.ru написать"
mapping = {"test@mail.ru": "email_0001@fake"}
keys = sorted(mapping.keys(), key=len, reverse=True)
result = apply_replacements(text, mapping, keys)
check("email заменён", "email_0001@fake" in result)
# 4. Множественные замены
text = "Иванов И.И. и Петров А.С. подписали"
mapping = {"Иванов И.И.": "Иванов_0001", "Петров А.С.": "Петров_0002"}
keys = sorted(mapping.keys(), key=len, reverse=True)
result = apply_replacements(text, mapping, keys)
check("две замены", "Иванов_0001" in result and "Петров_0002" in result)
# 5. Спецсимволы в ключе
text = 'ООО "Ромашка" заключила'
mapping = {'ООО "Ромашка"': "ООО_Технология_0001"}
keys = sorted(mapping.keys(), key=len, reverse=True)
result = apply_replacements(text, mapping, keys)
check("кавычки в ключе", "ООО_Технология_0001" in result)
# 6. Кириллица
text = "Москва, ул. Ленина, д. 42"
mapping = {"Москва": "Москва_0001", "Ленина": "Ленина_0015"}
keys = sorted(mapping.keys(), key=len, reverse=True)
result = apply_replacements(text, mapping, keys)
check("кириллица заменена", "Москва_0001" in result and "Ленина_0015" in result)
# 7. Частичное совпадение не заменяется
text = "Ивановский проспект"
mapping = {"Иванов": "Иванов_0001"}
keys = sorted(mapping.keys(), key=len, reverse=True)
result = apply_replacements(text, mapping, keys)
check("частичное совпадение — не заменено", result == "Ивановский проспект")
# 8. Пустой mapping
text = "ничего не меняется"
result = apply_replacements(text, {}, [])
check("пустой mapping", result == text)
# 9. Markdown текст
text = "**ООО Ромашка** заключила с **Иванов И.И.**"
mapping = {"ООО Ромашка": "ООО_Технология_0001", "Иванов И.И.": "Иванов_0002"}
keys = sorted(mapping.keys(), key=len, reverse=True)
result = apply_replacements(text, mapping, keys)
check("Markdown bold сохранён", "**ООО_Технология_0001**" in result)
# ═══════════════════════════════════════════════════════════════
print(f"\n{'='*50}")
print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}")
if failed > 0:
print("❌ ТЕСТЫ ПРОВАЛЕНЫ")
sys.exit(1)
else:
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
+131
View File
@@ -0,0 +1,131 @@
"""
Тесты scanner.py — scan_regex, _next_token.
Запуск: python3 tests/test_scanner.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from drhider.scanner import scan_regex, _next_token
passed = 0
failed = 0
def check(name, condition):
global passed, failed
if condition:
passed += 1
print(f"{name}")
else:
failed += 1
print(f"{name}")
# ═══════════════════════════════════════════════════════════════
# _next_token
# ═══════════════════════════════════════════════════════════════
print("\n=== _next_token ===")
counters = {}
# 1. Базовый токен
token = _next_token("person_name", counters)
check("person → Иванов/Петров/etc", token.endswith("_0001"))
check("счётчик увеличился", counters.get("person_Иванов", 0) == 1 or any(v == 1 for v in counters.values()))
# 2. Второй токен того же типа — новый номер
token2 = _next_token("person_name", counters)
check("второй person — новый счётчик", any(v >= 1 for v in counters.values()))
# 3. Разные типы — разные счётчики
token3 = _next_token("company", counters)
check("company ≠ person", token3 != token)
# 4. Неизвестный тип → fallback
counters2 = {}
token4 = _next_token("unknown_xyz", counters2)
check("неизвестный тип — Данные_0001", token4.startswith("Данные_") and "_0001" in token4)
# 5. phone
counters3 = {}
token5 = _next_token("phone", counters3)
check("phone — +7_000_000_0001", token5 == "+7_000_000_0001")
# 6. address
counters4 = {}
token6 = _next_token("address", counters4)
check("address — город_0001", token6.endswith("_0001") and len(token6) > 5)
# ═══════════════════════════════════════════════════════════════
# scan_regex
# ═══════════════════════════════════════════════════════════════
print("\n=== scan_regex ===")
# 1. Телефон
mapping = {}
counters = {}
scan_regex("Звоните +7 (495) 789-41-35", mapping, counters)
check("телефон найден", len(mapping) == 1)
check("телефон — токен", list(mapping.values())[0].startswith("+7_000_000_"))
# 2. Email
mapping = {}
counters = {}
scan_regex("Пишите на info@nubes.ru", mapping, counters)
check("email найден", len(mapping) == 1)
check("email — токен", list(mapping.values())[0].startswith("email_"))
# 3. ИНН
mapping = {}
counters = {}
scan_regex("ИНН 9706005293", mapping, counters)
check("ИНН найден", len(mapping) == 1)
check("ИНН — токен", list(mapping.values())[0].startswith("ИНН_"))
# 4. Компания
mapping = {}
counters = {}
scan_regex('ООО "Ромашка" заключила договор', mapping, counters)
check("компания найдена", len(mapping) == 1)
check("компания — токен", "_" in list(mapping.values())[0])
# 5. ФИО
mapping = {}
counters = {}
scan_regex("Директор Иванов И.И. подписал", mapping, counters)
check("ФИО найдено", len(mapping) == 1)
check("ФИО — токен", "_" in list(mapping.values())[0])
# 6. Множественные сущности
mapping = {}
counters = {}
scan_regex("ИНН 9706005293, КПП 772401001, ОГРН 1207700098759", mapping, counters)
check("три сущности", len(mapping) == 3)
# 7. Дубликаты не перезаписываются
mapping = {}
counters = {}
scan_regex("+7 (495) 789-41-35 и ещё +7 (495) 789-41-35", mapping, counters)
check("дубликат — одна запись", len(mapping) == 1)
# 8. Согласованность: один счётчик
mapping = {}
counters = {}
scan_regex("ИНН 9706005293 и КПП 772401001", mapping, counters)
inn_val = mapping["ИНН 9706005293"]
kpp_val = mapping["КПП 772401001"]
check("ИНН и КПП разные токены", inn_val != kpp_val)
# ═══════════════════════════════════════════════════════════════
print(f"\n{'='*50}")
print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}")
if failed > 0:
print("❌ ТЕСТЫ ПРОВАЛЕНЫ")
sys.exit(1)
else:
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
+275
View File
@@ -0,0 +1,275 @@
"""
Тесты POST /api/upload — загрузка файлов в сессию.
Запуск:
python3 tests/test_upload.py
"""
import io
import os
import sys
import time
import json
import gc
# Настраиваем пути
_site = os.path.join(os.path.dirname(__file__), "..", "site")
sys.path.insert(0, _site)
sys.path.insert(0, os.path.dirname(_site)) # project root for drhider
from flask import Flask
from routes.api_bp import api_bp
# ═══════════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════════
def _new_client():
"""Создать свежий тестовый клиент."""
app = Flask(__name__)
app.register_blueprint(api_bp)
return app.test_client()
def _get_rss():
"""RSS процесса в байтах (Linux)."""
try:
with open("/proc/self/statm", "r") as f:
parts = f.read().split()
return int(parts[1]) * 4096
except Exception:
return 0
# ═══════════════════════════════════════════════════════════════════════════
# Базовые тесты
# ═══════════════════════════════════════════════════════════════════════════
def test_no_file():
"""POST без файла → 400."""
c = _new_client()
rv = c.post("/api/upload")
assert rv.status_code == 400
data = json.loads(rv.data)
assert data["ok"] is False
assert "No file" in data["error"]
def test_no_filename():
"""POST с файлом без имени → 400."""
c = _new_client()
data = {"files": (io.BytesIO(b"hello"), "")}
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
assert rv.status_code == 400
resp = json.loads(rv.data)
assert resp["ok"] is False
assert "filename" in resp["error"].lower()
def test_single_file():
"""POST с одним файлом → 200, сессия + count=1."""
c = _new_client()
data = {"files": (io.BytesIO(b"hello world"), "test.txt")}
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
assert rv.status_code == 200
resp = json.loads(rv.data)
assert resp["ok"] is True
assert len(resp["session"]) == 12
assert resp["count"] == 1
def test_two_files_same_session():
"""Два файла в одну сессию → count=2."""
c = _new_client()
data1 = {"files": (io.BytesIO(b"first"), "file1.txt")}
rv1 = c.post("/api/upload", data=data1, content_type="multipart/form-data")
assert rv1.status_code == 200
sid = json.loads(rv1.data)["session"]
data2 = {
"files": (io.BytesIO(b"second"), "file2.txt"),
"session": sid,
}
rv2 = c.post("/api/upload", data=data2, content_type="multipart/form-data")
assert rv2.status_code == 200
resp2 = json.loads(rv2.data)
assert resp2["session"] == sid
assert resp2["count"] == 2
def test_fake_session_404():
"""Несуществующая сессия → 404."""
c = _new_client()
data = {
"files": (io.BytesIO(b"data"), "f.txt"),
"session": "deadbeef1234",
}
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
assert rv.status_code == 404
resp = json.loads(rv.data)
assert resp["ok"] is False
def test_empty_file():
"""Пустой файл (0 байт) → 200."""
c = _new_client()
data = {"files": (io.BytesIO(b""), "empty.txt")}
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
assert rv.status_code == 200
resp = json.loads(rv.data)
assert resp["ok"] is True
assert resp["count"] == 1
def test_cyrillic_filename():
"""Кириллическое имя файла → 200."""
c = _new_client()
content = "кириллица".encode("utf-8")
data = {"files": (io.BytesIO(content), "договор_№123.txt")}
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
assert rv.status_code == 200
resp = json.loads(rv.data)
assert resp["ok"] is True
assert resp["count"] == 1
def test_content_type_json():
"""Ответ — application/json."""
c = _new_client()
data = {"files": (io.BytesIO(b"x"), "f.txt")}
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
assert rv.status_code == 200
assert "application/json" in rv.content_type
# ═══════════════════════════════════════════════════════════════════════════
# Большие файлы
# ═══════════════════════════════════════════════════════════════════════════
def test_large_10mb():
"""Файл 10 MB — загружается без ошибок."""
c = _new_client()
size = 10 * 1024 * 1024
big_data = b"A" * size
data = {"files": (io.BytesIO(big_data), "big_10mb.bin")}
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
assert rv.status_code == 200
resp = json.loads(rv.data)
assert resp["ok"] is True
assert resp["count"] == 1
def test_large_50mb():
"""Файл 50 MB — загружается без ошибок."""
c = _new_client()
size = 50 * 1024 * 1024
big_data = b"B" * size
data = {"files": (io.BytesIO(big_data), "big_50mb.bin")}
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
assert rv.status_code == 200
resp = json.loads(rv.data)
assert resp["ok"] is True
assert resp["count"] == 1
# ═══════════════════════════════════════════════════════════════════════════
# Много мелких файлов
# ═══════════════════════════════════════════════════════════════════════════
def test_many_small_files():
"""100 файлов по 1 KB в одну сессию."""
c = _new_client()
data1 = {"files": (io.BytesIO(b"x" * 1024), "file_000.txt")}
rv1 = c.post("/api/upload", data=data1, content_type="multipart/form-data")
sid = json.loads(rv1.data)["session"]
for i in range(1, 100):
data = {
"files": (io.BytesIO(b"y" * 1024), f"file_{i:03d}.txt"),
"session": sid,
}
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
assert rv.status_code == 200
resp = json.loads(rv.data)
assert resp["session"] == sid
assert resp["count"] == i + 1
assert resp["count"] == 100
# ═══════════════════════════════════════════════════════════════════════════
# Утечка памяти
# ═══════════════════════════════════════════════════════════════════════════
def test_memory_after_large_files():
"""После загрузки больших файлов память не течёт."""
c = _new_client()
gc.collect()
mem_before = _get_rss()
for i in range(10):
data = {"files": (io.BytesIO(b"M" * (1024 * 1024)), f"memtest_{i}.bin")}
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
assert rv.status_code == 200
gc.collect()
mem_after = _get_rss()
diff_mb = (mem_after - mem_before) / (1024 * 1024)
assert diff_mb < 50, f"Memory grew {diff_mb:.1f} MB, expected < 50 MB"
# ═══════════════════════════════════════════════════════════════════════════
# Бенчмарк скорости
# ═══════════════════════════════════════════════════════════════════════════
def test_upload_speed():
"""1 MB файл — загрузка < 500 ms."""
c = _new_client()
data = {"files": (io.BytesIO(b"S" * (1024 * 1024)), "speed.bin")}
start = time.time()
rv = c.post("/api/upload", data=data, content_type="multipart/form-data")
elapsed = time.time() - start
assert rv.status_code == 200
assert elapsed < 0.5, f"Upload took {elapsed:.3f}s, expected < 0.5s"
# ═══════════════════════════════════════════════════════════════════════════
# Основной блок
# ═══════════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
passed = 0
failed = 0
tests = [
test_no_file,
test_no_filename,
test_single_file,
test_two_files_same_session,
test_fake_session_404,
test_empty_file,
test_cyrillic_filename,
test_content_type_json,
test_large_10mb,
test_large_50mb,
test_many_small_files,
test_memory_after_large_files,
test_upload_speed,
]
for test in tests:
try:
test()
print(f"{test.__name__}")
passed += 1
except Exception as e:
print(f"{test.__name__}: {e}")
failed += 1
print(f"\n{'='*50}")
print(f"Результат: {passed} OK, {failed} FAIL из {len(tests)}")
if failed == 0:
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
else:
print("❌ ЕСТЬ ПРОВАЛЫ")
sys.exit(1)
+228
View File
@@ -0,0 +1,228 @@
"""
Тесты нативного ZIP: unzip → buildZip.
Эмулирует JS-логику:
1. Сервер возвращает ZIP с .md + mapping.csv
2. unzip() распаковывает
3. buildZip() перепаковывает без mapping.csv
4. Результат должен быть валидным ZIP
Запуск: python3 tests/test_zip.py
"""
import io
import zipfile
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
passed = 0
failed = 0
def check(name, condition):
global passed, failed
if condition:
passed += 1
print(f"{name}")
else:
failed += 1
print(f"{name}")
# ═══════════════════════════════════════════════════════════════
# Test 1: roundtrip — server ZIP → extract → rebuild → verify
# ═══════════════════════════════════════════════════════════════
print("\n=== Тест 1: unzip + buildZip roundtrip ===")
# Создаём ZIP как сервер
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr("договор_obfuscated.md", "# Заголовок\nТекст договора с Иванов_0001.")
zf.writestr("спецификация_obfuscated.md", "| Услуга | Цена |\n|---|---|\n| Хостинг | 1000 |")
zf.writestr("mapping.csv", "тип,оригинал,замена\nperson,Иванов И.И.,Иванов_0001\n")
server_zip = buf.getvalue()
# Распаковываем (эмуляция unzip)
with zipfile.ZipFile(io.BytesIO(server_zip)) as zf:
extracted = {n: zf.read(n) for n in zf.namelist()}
check("3 файла в ZIP", len(extracted) == 3)
check("mapping.csv есть", "mapping.csv" in extracted)
check("договор есть", "договор_obfuscated.md" in extracted)
# Отделяем mapping от файлов
md_files = {n: d for n, d in extracted.items() if n != "mapping.csv"}
mapping_data = extracted.get("mapping.csv", b"").decode("utf-8")
check("mapping читается", "Иванов_0001" in mapping_data)
check("договор читается", "Иванов_0001" in md_files["договор_obfuscated.md"].decode("utf-8"))
# Перепаковываем без mapping.csv (эмуляция buildZip)
buf2 = io.BytesIO()
with zipfile.ZipFile(buf2, 'w') as zf:
for name, data in md_files.items():
zf.writestr(name, data)
rebuilt_zip = buf2.getvalue()
# Проверяем перепакованный ZIP
with zipfile.ZipFile(io.BytesIO(rebuilt_zip)) as zf:
names = zf.namelist()
check("mapping.csv НЕТ в перепакованном", "mapping.csv" not in names)
check("договор есть в перепакованном", "договор_obfuscated.md" in names)
check("спецификация есть в перепакованном", "спецификация_obfuscated.md" in names)
content = zf.read("договор_obfuscated.md").decode("utf-8")
check("содержимое договора сохранено", "Иванов_0001" in content)
# ═══════════════════════════════════════════════════════════════
# Test 2: ZIP с одним файлом
# ═══════════════════════════════════════════════════════════════
print("\n=== Тест 2: один файл без mapping ===")
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr("file.md", "content")
server_zip = buf.getvalue()
with zipfile.ZipFile(io.BytesIO(server_zip)) as zf:
extracted = {n: zf.read(n) for n in zf.namelist()}
md_files = {n: d for n, d in extracted.items() if n != "mapping.csv"}
buf2 = io.BytesIO()
with zipfile.ZipFile(buf2, 'w') as zf:
for name, data in md_files.items():
zf.writestr(name, data)
with zipfile.ZipFile(io.BytesIO(buf2.getvalue())) as zf:
check("один файл", len(zf.namelist()) == 1)
check("контент сохранён", zf.read("file.md").decode("utf-8") == "content")
# ═══════════════════════════════════════════════════════════════
# Test 3: .doc файл (binary, не меняется)
# ═══════════════════════════════════════════════════════════════
print("\n=== Тест 3: бинарный .doc ===")
binary_data = bytes([0xD0, 0xCF, 0x11, 0xE0]) + b"\x00" * 100 # OLE2 magic
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr("file_obfuscated.doc", binary_data)
zf.writestr("mapping.csv", "тип,оригинал,замена\n")
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
extracted = {n: zf.read(n) for n in zf.namelist()}
md_files = {n: d for n, d in extracted.items() if n != "mapping.csv"}
buf2 = io.BytesIO()
with zipfile.ZipFile(buf2, 'w') as zf:
for name, data in md_files.items():
zf.writestr(name, data)
with zipfile.ZipFile(io.BytesIO(buf2.getvalue())) as zf:
check(".doc сохранён", "file_obfuscated.doc" in zf.namelist())
content = zf.read("file_obfuscated.doc")
check("размер сохранён", len(content) == len(binary_data))
check("данные сохранены", content == binary_data)
# ═══════════════════════════════════════════════════════════════
# Test 4: CSV с кириллицей и кавычками
# ═══════════════════════════════════════════════════════════════
print("\n=== Тест 4: CSV с кириллицей ===")
csv_content = 'тип,оригинал,замена\ncompany,"ООО ""НУБЕС""",ООО_Технология_0001\nperson,Иванов И.И.,Иванов_0002\n'
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr("test.md", "text")
zf.writestr("mapping.csv", csv_content)
with zipfile.ZipFile(io.BytesIO(buf.getvalue())) as zf:
mapping = zf.read("mapping.csv").decode("utf-8")
check("BOM есть (\\ufeff)", mapping.startswith('\ufeff') or mapping.startswith('тип'))
check("НУБЕС в mapping", "НУБЕС" in mapping)
check("Иванов в mapping", "Иванов" in mapping)
# ═══════════════════════════════════════════════════════════════
# Test 5: множественные mapping.csv объединяются
# ═══════════════════════════════════════════════════════════════
print("\n=== Тест 5: объединение mapping.csv ===")
all_mappings = []
for csv_text in [
"тип,оригинал,замена\nperson,Иванов,Иванов_0001\n",
"тип,оригинал,замена\nperson,Петров,Петров_0002\ncompany,Ромашка,Ромашка_0001\n",
]:
lines = csv_text.split("\n")
if len(all_mappings) == 0:
all_mappings.append(lines[0]) # заголовок
all_mappings.append(lines[1]) # первая строка
else:
all_mappings.append(lines[1]) # только данные, без заголовка
if len(lines) > 2 and lines[2]:
all_mappings.append(lines[2])
result = "\n".join(all_mappings)
check("заголовок один", result.count("тип,оригинал,замена") == 1)
check("Иванов есть", "Иванов" in result)
check("Петров есть", "Петров" in result)
check("Ромашка есть", "Ромашка" in result)
# ═══════════════════════════════════════════════════════════════
# Test 6: точные смещения в local file header (баг start+18)
# ═══════════════════════════════════════════════════════════════
print("\n=== Тест 6: валидация смещений local file header ===")
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr("test.md", "Hello World")
raw = buf.getvalue()
import struct
sig_pos = raw.find(b'PK\x03\x04')
check("сигнатура найдена", sig_pos >= 0)
def u16(off): return struct.unpack_from('<H', raw, off)[0]
def u32(off): return struct.unpack_from('<I', raw, off)[0]
start = sig_pos
check("signature = 0x04034b50", u32(start) == 0x04034b50)
name_len = u16(start + 26)
extra_len = u16(start + 28)
comp_size = u32(start + 18) # смещение которое было сломано
uncomp_size = u32(start + 22)
check("compressed size > 0", comp_size > 0)
check("compressed == uncompressed", comp_size == uncomp_size)
check("filename length", name_len == len("test.md"))
name_start = start + 30
name = raw[name_start:name_start + name_len].decode('ascii')
check("имя файла", name == "test.md")
data_start = name_start + name_len + extra_len
data = raw[data_start:data_start + comp_size].decode('ascii')
check("данные файла", data == "Hello World")
# ═══════════════════════════════════════════════════════════════
# Итог
# ═══════════════════════════════════════════════════════════════
print(f"\n{'='*50}")
print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}")
if failed > 0:
print("❌ ТЕСТЫ ПРОВАЛЕНЫ")
sys.exit(1)
else:
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")