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
_Дата: 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)
# Версия приложения (меняется при изменениях)
VERSION = "0.0.57"
VERSION = "0.0.58"
def setup_logging():
+43
View File
@@ -16,6 +16,7 @@ import threading
import zipfile
import traceback
import logging
import httpx
from datetime import datetime, timedelta
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)})
@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"])
def process_stream(sid):
"""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(); // имена файлов сверх лимита (не участвуют в обфускации)
const MAX_FILE_BYTES = 100 * 1024 * 1024; // 100 МБ на файл
const MAX_SESSION_BYTES = 1024 * 1024 * 1024; // 1 ГБ суммарно
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
let currentSid = '';
let activeES = null; // активный EventSource
let activeXHR = null; // активный XHR
@@ -434,23 +435,22 @@ async function uploadFiles() {
const total = toSend.length;
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++) {
const f = toSend[k];
const i = sendIdx[k]; // индекс в sf для вывода прогресса
const n = k + 1;
st.className = 'status progress';
st.textContent = 'Загрузка (этап 1/2) ' + n + '/' + total + ': ' + f.name;
st.textContent = 'Загрузка на ВМ (этап 1/2) ' + n + '/' + total + ': ' + f.name;
try {
const t0 = performance.now();
const vmUrl = VM_UPLOAD_URL + token + '_' + k; // имя файла в URL не несём (токен-ключ)
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 = 300000; // 300с: загрузка больших файлов нестабильна через шлюз
xhr.open('PUT', vmUrl);
xhr.timeout = 300000; // 300с: большие файлы
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const pct = Math.round(e.loaded / e.total * 100);
@@ -458,35 +458,49 @@ async function uploadFiles() {
}
};
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));
}
if (xhr.status >= 200 && xhr.status < 300) {
const elapsed = (performance.now() - t0) / 1000;
const speed = f.size / elapsed;
ss(i, '<span style="color:#22c55e">✓ ' + fs(speed) + '/s</span>');
refs.push({ name: f.name, size: f.size, url: vmUrl });
resolve();
} else {
reject(new Error('HTTP ' + xhr.status));
reject(new Error('ВМ: HTTP ' + xhr.status));
}
};
xhr.onerror = function() { reject(new Error('Сеть')); };
xhr.ontimeout = function() { reject(new Error('Таймаут 300с')); };
xhr.onerror = function() { reject(new Error('Сеть (ВМ)')); };
xhr.ontimeout = function() { reject(new Error('Таймаут 300с (ВМ)')); };
activeXHR = xhr;
xhr.send(fd);
xhr.send(f); // сырое тело файла
});
} catch (err) {
ss(i, '<span style="color:#ef4444">✗</span>');
st.className = 'status error';
st.textContent = 'Ошибка загрузки: ' + err.message;
st.textContent = 'Ошибка загрузки на ВМ: ' + err.message;
ub.disabled = false;
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 — прогресс по каждому файлу)
// Сброс загрузочных статусов — теперь этап обработки (только отправленные)
for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>');