From f3a89529471482e82ae30aff99ff08e9ec273ecf Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 20:01:46 +1000 Subject: [PATCH] fix: friendly 502 handling, 2 uvicorn workers, file bundle cache (0.20.18) --- backend/app/services/rdp_bundle_delivery.py | 56 ++++++++++----------- backend/app/version.py | 2 +- backend/tests/test_winrm_connect.py | 6 ++- deploy/sac-deploy.sh | 2 +- deploy/systemd/sac-api.service | 2 +- frontend/src/api.ts | 25 +++++++-- frontend/src/version.ts | 2 +- frontend/src/views/HostsView.vue | 8 ++- 8 files changed, 63 insertions(+), 40 deletions(-) diff --git a/backend/app/services/rdp_bundle_delivery.py b/backend/app/services/rdp_bundle_delivery.py index f45ce8c..6645e3d 100644 --- a/backend/app/services/rdp_bundle_delivery.py +++ b/backend/app/services/rdp_bundle_delivery.py @@ -1,26 +1,19 @@ -"""Short-lived in-memory RDP agent bundles for WinRM clients to download.""" +"""Short-lived RDP agent bundles for WinRM clients to download.""" from __future__ import annotations import io +import re import secrets import time import zipfile from pathlib import Path -from threading import Lock -TTL_SECONDS = 600 _UTF8_BOM = b"\xef\xbb\xbf" _BOM_SUFFIXES = {".ps1", ".txt"} - -_lock = Lock() -_entries: dict[str, tuple[bytes, float]] = {} - - -def _prune_expired(now: float) -> None: - expired = [token for token, (_, expires_at) in _entries.items() if expires_at <= now] - for token in expired: - _entries.pop(token, None) +_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,128}$") +TTL_SECONDS = 600 +BUNDLE_DIR = Path("/tmp/sac-rdp-bundles") def _bundle_file_bytes(path: Path) -> bytes: @@ -43,27 +36,34 @@ def build_rdp_bundle_zip(repo_dir: Path, filenames: tuple[str, ...]) -> bytes: return buffer.getvalue() +def _prune_expired_bundles(now: float | None = None) -> None: + if not BUNDLE_DIR.is_dir(): + return + cutoff = (now or time.time()) - TTL_SECONDS + for path in BUNDLE_DIR.glob("*.zip"): + try: + if path.stat().st_mtime < cutoff: + path.unlink(missing_ok=True) + except OSError: + continue + + def register_rdp_bundle_zip(zip_bytes: bytes) -> str: token = secrets.token_urlsafe(32) - expires_at = time.time() + TTL_SECONDS - with _lock: - _prune_expired(time.time()) - _entries[token] = (zip_bytes, expires_at) + BUNDLE_DIR.mkdir(parents=True, exist_ok=True) + _prune_expired_bundles() + (BUNDLE_DIR / f"{token}.zip").write_bytes(zip_bytes) return token def get_rdp_bundle_zip(token: str) -> bytes | None: text = (token or "").strip() - if not text: + if not _TOKEN_RE.fullmatch(text): return None - now = time.time() - with _lock: - _prune_expired(now) - entry = _entries.get(text) - if entry is None: - return None - data, expires_at = entry - if expires_at <= now: - _entries.pop(text, None) - return None - return data + path = BUNDLE_DIR / f"{text}.zip" + if not path.is_file(): + return None + if time.time() - path.stat().st_mtime > TTL_SECONDS: + path.unlink(missing_ok=True) + return None + return path.read_bytes() diff --git a/backend/app/version.py b/backend/app/version.py index 8af1250..633e22d 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.20.17" +APP_VERSION = "0.20.18" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_winrm_connect.py b/backend/tests/test_winrm_connect.py index 222edab..778a628 100644 --- a/backend/tests/test_winrm_connect.py +++ b/backend/tests/test_winrm_connect.py @@ -70,7 +70,8 @@ def test_custom_deploy_script_escapes_single_quotes(): assert "O''Brien" in script -def test_rdp_bundle_zip_adds_utf8_bom_for_ps1(tmp_path: Path): +def test_rdp_bundle_zip_adds_utf8_bom_for_ps1(tmp_path: Path, monkeypatch): + monkeypatch.setattr("app.services.rdp_bundle_delivery.BUNDLE_DIR", tmp_path) repo = tmp_path / "repo" repo.mkdir() (repo / "Deploy-LoginMonitor.ps1").write_text("# test", encoding="utf-8") @@ -83,7 +84,8 @@ def test_rdp_bundle_zip_adds_utf8_bom_for_ps1(tmp_path: Path): assert data.startswith(_UTF8_BOM) -def test_rdp_bundle_zip_roundtrip(tmp_path: Path): +def test_rdp_bundle_zip_roundtrip(tmp_path: Path, monkeypatch): + monkeypatch.setattr("app.services.rdp_bundle_delivery.BUNDLE_DIR", tmp_path) repo = tmp_path / "repo" repo.mkdir() for name in RDP_BUNDLE_REQUIRED: diff --git a/deploy/sac-deploy.sh b/deploy/sac-deploy.sh index 9a59b46..6f21296 100644 --- a/deploy/sac-deploy.sh +++ b/deploy/sac-deploy.sh @@ -104,7 +104,7 @@ print('db: OK') \" " || die "PostgreSQL: проверьте DATABASE_URL в ${CONFIG_FILE} и systemctl status postgresql" -log "systemctl restart ${SERVICE_NAME}" +log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)" systemctl restart "${SERVICE_NAME}" systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active" diff --git a/deploy/systemd/sac-api.service b/deploy/systemd/sac-api.service index 6920d4c..289594c 100644 --- a/deploy/systemd/sac-api.service +++ b/deploy/systemd/sac-api.service @@ -13,7 +13,7 @@ WorkingDirectory=/opt/security-alert-center/backend # Конфиг читает само приложение (pydantic), не systemd — иначе ломаются пароли с #, $ и т.д. Environment=SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env Environment=PYTHONPATH=/opt/security-alert-center/backend -ExecStart=/opt/security-alert-center/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --timeout-graceful-shutdown 10 +ExecStart=/opt/security-alert-center/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2 --timeout-graceful-shutdown 30 Restart=on-failure RestartSec=5 TimeoutStopSec=20 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d94861e..76f860e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -62,20 +62,20 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise clearToken(); redirectToErrorPage(401); } - throw new ApiError(parseApiError(text) || "Unauthorized", 401); + throw new ApiError(parseApiError(text, 401) || "Unauthorized", 401); } if (res.status === 403) { if (hadToken) { redirectToErrorPage(403); } - throw new ApiError(parseApiError(text) || "Forbidden", 403); + throw new ApiError(parseApiError(text, 403) || "Forbidden", 403); } if (res.status === 429) { redirectToErrorPage(429); - throw new ApiError(parseApiError(text) || "Too many requests", 429); + throw new ApiError(parseApiError(text, 429) || "Too many requests", 429); } if (!res.ok) { - throw new ApiError(parseApiError(text) || res.statusText, res.status); + throw new ApiError(parseApiError(text, res.status) || res.statusText, res.status); } if (!text) { return undefined as T; @@ -99,8 +99,23 @@ export function fetchMe(): Promise { return apiFetch("/api/v1/auth/me"); } -export function parseApiError(text: string): string { +export function parseApiError(text: string, status?: number): string { + if (status === 502 || status === 503 || status === 504) { + if (/\s*502 Bad Gateway/i.test(text) || /502 Bad Gateway/i.test(text)) { + return "Сервер SAC временно недоступен (перезапуск или обновление). Подождите 5–10 секунд и обновите страницу."; + } + if (/<title>\s*503 Service Unavailable/i.test(text) || /503 Service Unavailable/i.test(text)) { + return "SAC временно перегружен. Повторите запрос через несколько секунд."; + } + if (/<title>\s*504 Gateway Time-out/i.test(text) || /504 Gateway Time-out/i.test(text)) { + return "Превышено время ожидания ответа SAC. Долгая операция могла ещё выполняться на сервере."; + } + return "Временная ошибка шлюза SAC. Обновите страницу."; + } if (!text) return ""; + if (text.trimStart().startsWith("<")) { + return status ? `Ошибка HTTP ${status}` : "Ошибка сервера"; + } try { const data = JSON.parse(text) as { detail?: string | Array<{ msg?: string }> }; if (typeof data.detail === "string") return data.detail; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 79875d5..24989af 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.20.17"; +export const APP_VERSION = "0.20.18"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; diff --git a/frontend/src/views/HostsView.vue b/frontend/src/views/HostsView.vue index f31bb05..7aafb2b 100644 --- a/frontend/src/views/HostsView.vue +++ b/frontend/src/views/HostsView.vue @@ -101,6 +101,7 @@ import { useRoute, useRouter } from "vue-router"; import { useSacLiveEventStream } from "../composables/useSacLiveEventStream"; import { apiFetch, + ApiError, fetchHost, fetchRecentEvents, type HostDetail, @@ -304,7 +305,7 @@ const sortedItems = computed(() => { return items; }); -async function loadHosts() { +async function loadHosts(retry = 0) { loading.value = true; error.value = ""; try { @@ -314,6 +315,11 @@ async function loadHosts() { if (agentStatusFilter.value) params.set("agent_status", agentStatusFilter.value); data.value = await apiFetch<HostListResponse>(`/api/v1/hosts?${params}`); } catch (e) { + const status = e instanceof ApiError ? e.status : 0; + if (retry < 2 && (status === 502 || status === 503)) { + await new Promise((resolve) => window.setTimeout(resolve, 2500)); + return loadHosts(retry + 1); + } error.value = e instanceof Error ? e.message : "Ошибка"; } finally { loading.value = false;