v0.0.58: ВМ-загрузка (паттерн ВМ-буфер+pull) — PUT на ВМ + /api/upload_refs (egress), nginx /drhider-upload/ + CORS + TTL
Deploy drhider / validate (push) Canceled after 0s

This commit is contained in:
“Naeel”
2026-08-21 15:40:24 +03:00
parent 705b39ab7d
commit 04f6d7f81a
6 changed files with 5308 additions and 26 deletions
@@ -1,7 +1,7 @@
# ПЛАН: перенос паттерна «ВМ-буфер + pull» в drhider # ПЛАН: перенос паттерна «ВМ-буфер + pull» в drhider
_Дата: 2026-08-21. Основа: `contracts/loadtest/History/2026-08-21-pattern-vm-buffer-pull.md`._ _Дата: 2026-08-21. Основа: `contracts/loadtest/History/2026-08-21-pattern-vm-buffer-pull.md`._
_Статус: план, код НЕ менялся. Писать будет DeepSeek Flash._ _Статус: РЕАЛИЗОВАНО 2026-08-21 (v0.0.58), см. `2026-08-21-vm-upload-implemented.md`._
--- ---
@@ -0,0 +1,43 @@
# ВМ-загрузка в drhider — реализовано (v0.0.58, 2026-08-21)
_Реализация паттерна «ВМ-буфер + pull» по плану `2026-08-21-pattern-vm-buffer-pull-plan.md`._
_Снимок до изменений: ветка `save-pre-vm-upload-2026-08-21`._
## Что сделано
1. **Flask** (`site/routes/api_bp.py`) — новый `POST /api/upload_refs`:
принимает JSON `{session, files:[{name,size,url}]}`, тянет каждый файл с ВМ
ИСХОДЯЩИМ GET (httpx, stream по частям), кладёт в сессию `add_file()`,
после успешного pull делает DELETE с ВМ. Ошибки egress → 502.
2. **Фронт** (`site/templates/index.html`, `uploadFiles()`) — Фаза 1 переделана:
- 1a: каждый файл → `PUT` на ВМ `https://contracts.kube5s.ru/drhider-upload/<токен>_<k>`
(XHR, прогресс по-прежнему в строке), собирает refs.
- 1b: один маленький `POST /api/upload_refs` (<64КБ).
- Токен — `crypto.randomUUID()`. Константа `VM_UPLOAD_URL`.
3. **ВМ** (`5.172.178.213`) — nginx `nginx-contracts.conf`:
- `location /drhider-upload/` → `alias /var/www/drhider-upload/`,
`dav_methods PUT DELETE`, `create_full_put_path on`, `client_max_body_size 1024m`,
CORS-заголовки для `drhider.pythonk8s.dev.nubes.ru`, OPTIONS → 204.
- Каталог `/var/www/drhider-upload/` (www-data).
- Backup: `/tmp/nginx-contracts.conf.bak.20260821`.
- TTL-чистка в root cron: `*/5 * * * * find /var/www/drhider-upload -type f -mmin +30 -delete`.
4. **Версия** — `site/app.py`: 0.0.57 → 0.0.58.
## Отклонение от плана
- URL файла на ВМ — БЕЗ имени: `.../drhider-upload/<токен>_<k>` (имя — только в метаданных).
Иначе кириллица/пробелы в имени ломали бы URL.
## Тесты (2026-08-21)
- ВМ: PUT 10МБ → 201 (1.0с); GET → 200, 10485760B; OPTIONS preflight → 204 с CORS;
DELETE → 204; GET после DELETE → 404.
- Сквозной: PUT на ВМ → `POST /api/upload_refs` → 200, файл в сессии (10485760B),
файл с ВМ удалён (404).
- `py_compile` api_bp/app — OK; `node --check` JS из index.html — OK.
## Замечания
- ГРАБЛИ: на Krupski `dd` алиасится на `docker compose down` — для генерации файлов
использовать `head -c N /dev/urandom > file`.
- Локальные curl/httpx к contracts.kube5s.ru — через прокси (`172.17.192.1:10808`):
для тестов `--noproxy '*'` / `NO_PROXY=contracts.kube5s.ru`.
- Прод-безопасность (позже): токены без TTL-времени в самом URL, авторизация на ВМ
(сейчас ключ — случайный UUID, файл живёт ≤30 мин по cron).
Executable
+5182
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -21,7 +21,7 @@ if _sys_path_root not in sys.path:
sys.path.insert(0, _sys_path_root) sys.path.insert(0, _sys_path_root)
# Версия приложения (меняется при изменениях) # Версия приложения (меняется при изменениях)
VERSION = "0.0.57" VERSION = "0.0.58"
def setup_logging(): def setup_logging():
+43
View File
@@ -16,6 +16,7 @@ import threading
import zipfile import zipfile
import traceback import traceback
import logging import logging
import httpx
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context from flask import Blueprint, request, send_file, jsonify, Response, stream_with_context
@@ -65,6 +66,48 @@ def upload():
return jsonify({"ok": True, "session": sid, "count": file_count(sid)}) return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
@api_bp.route("/upload_refs", methods=["POST"])
def upload_refs():
"""Принять ссылки на файлы (загружены на ВМ-буфер), забрать по egress.
Вход: JSON {"session": "...", "files": [{"name": str, "size": int, "url": str}]}.
Каждый файл тянется ИСХОДЯЩИМ GET'ом с ВМ (egress не ограничен шлюзом),
читается по частям (stream), кладётся в сессию. После успешного pull файл
удаляется с ВМ (best-effort; TTL-чистка на ВМ тоже есть).
"""
data = request.get_json(silent=True) or {}
sid = data.get("session") or create_session()
refs = data.get("files") or []
if not refs:
log.warning("upload_refs: no files, sid=%s", sid)
return jsonify({"ok": False, "error": "No files"}), 400
added = 0
try:
with httpx.Client(timeout=120, follow_redirects=True) as client:
for ref in refs:
name = ref.get("name")
url = ref.get("url")
if not name or not url:
continue
with client.stream("GET", url) as resp:
resp.raise_for_status()
content = b"".join(resp.iter_bytes())
log.info("upload_refs: pulled sid=%s file=%r size=%d", sid, name, len(content))
if not add_file(sid, name, content):
log.warning("upload_refs: session not found/limit, sid=%s file=%r", sid, name)
return jsonify({"ok": False, "error": "Session not found"}), 404
try:
client.delete(url) # убрать файл с ВМ после загрузки
except Exception:
pass
added += 1
except Exception as e:
log.error("upload_refs: pull error sid=%s: %r", sid, e)
return jsonify({"ok": False, "error": "Pull failed: %s" % e}), 502
log.info("upload_refs: done sid=%s added=%d total=%d", sid, added, file_count(sid))
return jsonify({"ok": True, "session": sid, "count": file_count(sid)})
@api_bp.route("/process_stream/<sid>", methods=["GET"]) @api_bp.route("/process_stream/<sid>", methods=["GET"])
def process_stream(sid): def process_stream(sid):
"""SSE: process all session files, streaming per-file progress. """SSE: process all session files, streaming per-file progress.
+38 -24
View File
@@ -198,6 +198,7 @@ let fileMeta = new Map(); // имя -> {size, mtime} для дедупа/суф
let overNames = new Set(); // имена файлов сверх лимита (не участвуют в обфускации) let overNames = new Set(); // имена файлов сверх лимита (не участвуют в обфускации)
const MAX_FILE_BYTES = 100 * 1024 * 1024; // 100 МБ на файл const MAX_FILE_BYTES = 100 * 1024 * 1024; // 100 МБ на файл
const MAX_SESSION_BYTES = 1024 * 1024 * 1024; // 1 ГБ суммарно const MAX_SESSION_BYTES = 1024 * 1024 * 1024; // 1 ГБ суммарно
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
let currentSid = ''; let currentSid = '';
let activeES = null; // активный EventSource let activeES = null; // активный EventSource
let activeXHR = null; // активный XHR let activeXHR = null; // активный XHR
@@ -434,23 +435,22 @@ async function uploadFiles() {
const total = toSend.length; const total = toSend.length;
if (total === 0) { st.className = 'status error'; st.textContent = 'Нет файлов для обфускации (все превышают лимит).'; ub.disabled = false; return; } if (total === 0) { st.className = 'status error'; st.textContent = 'Нет файлов для обфускации (все превышают лимит).'; ub.disabled = false; return; }
// Фаза 1: загрузка (XMLHttpRequest — прогресс в строке) // Фаза 1: загрузка на ВМ-буфер (PUT напрямую на nginx ВМ, минуя шлюз кластера)
const token = crypto.randomUUID();
const refs = []; // {name, size, url} — для POST /api/upload_refs
for (let k = 0; k < total; k++) { for (let k = 0; k < total; k++) {
const f = toSend[k]; const f = toSend[k];
const i = sendIdx[k]; // индекс в sf для вывода прогресса const i = sendIdx[k]; // индекс в sf для вывода прогресса
const n = k + 1; const n = k + 1;
st.className = 'status progress'; st.className = 'status progress';
st.textContent = 'Загрузка (этап 1/2) ' + n + '/' + total + ': ' + f.name; st.textContent = 'Загрузка на ВМ (этап 1/2) ' + n + '/' + total + ': ' + f.name;
try { try {
const t0 = performance.now(); const t0 = performance.now();
const vmUrl = VM_UPLOAD_URL + token + '_' + k; // имя файла в URL не несём (токен-ключ)
await new Promise((resolve, reject) => { 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(); const xhr = new XMLHttpRequest();
xhr.open('POST', url); xhr.open('PUT', vmUrl);
xhr.timeout = 300000; // 300с: загрузка больших файлов нестабильна через шлюз xhr.timeout = 300000; // 300с: большие файлы
xhr.upload.onprogress = function(e) { xhr.upload.onprogress = function(e) {
if (e.lengthComputable) { if (e.lengthComputable) {
const pct = Math.round(e.loaded / e.total * 100); const pct = Math.round(e.loaded / e.total * 100);
@@ -458,35 +458,49 @@ async function uploadFiles() {
} }
}; };
xhr.onload = function() { xhr.onload = function() {
if (xhr.status === 200) { if (xhr.status >= 200 && xhr.status < 300) {
const data = JSON.parse(xhr.responseText); const elapsed = (performance.now() - t0) / 1000;
if (data.ok) { const speed = f.size / elapsed;
const elapsed = (performance.now() - t0) / 1000; ss(i, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>');
const speed = f.size / elapsed; refs.push({ name: f.name, size: f.size, url: vmUrl });
ss(i, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>'); resolve();
currentSid = data.session;
resolve();
} else {
reject(new Error(data.error));
}
} else { } else {
reject(new Error('HTTP ' + xhr.status)); reject(new Error('ВМ: HTTP ' + xhr.status));
} }
}; };
xhr.onerror = function() { reject(new Error('Сеть')); }; xhr.onerror = function() { reject(new Error('Сеть (ВМ)')); };
xhr.ontimeout = function() { reject(new Error('Таймаут 300с')); }; xhr.ontimeout = function() { reject(new Error('Таймаут 300с (ВМ)')); };
activeXHR = xhr; activeXHR = xhr;
xhr.send(fd); xhr.send(f); // сырое тело файла
}); });
} catch (err) { } catch (err) {
ss(i, '<span style="color:#ef4444">✗</span>'); ss(i, '<span style="color:#ef4444">✗</span>');
st.className = 'status error'; st.className = 'status error';
st.textContent = 'Ошибка загрузки: ' + err.message; st.textContent = 'Ошибка загрузки на ВМ: ' + err.message;
ub.disabled = false; ub.disabled = false;
return; return;
} }
} }
// Шаг 1b: один маленький POST во Flask со ссылками на файлы ВМ (<64КБ)
st.className = 'status progress';
st.textContent = 'Передача ссылок в сервис…';
try {
const resp = await fetch('/api/upload_refs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: currentSid, files: refs })
});
const data = await resp.json();
if (!data.ok) throw new Error(data.error || 'HTTP ' + resp.status);
currentSid = data.session;
} catch (err) {
st.className = 'status error';
st.textContent = 'Ошибка передачи ссылок: ' + err.message;
ub.disabled = false;
return;
}
// Фаза 2: обработка (SSE — прогресс по каждому файлу) // Фаза 2: обработка (SSE — прогресс по каждому файлу)
// Сброс загрузочных статусов — теперь этап обработки (только отправленные) // Сброс загрузочных статусов — теперь этап обработки (только отправленные)
for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>'); for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>');