fix: friendly 502 handling, 2 uvicorn workers, file bundle cache (0.20.18)

This commit is contained in:
2026-06-20 20:01:46 +10:00
parent 4d9a9e7e2b
commit f3a8952947
8 changed files with 63 additions and 40 deletions
+28 -28
View File
@@ -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 from __future__ import annotations
import io import io
import re
import secrets import secrets
import time import time
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from threading import Lock
TTL_SECONDS = 600
_UTF8_BOM = b"\xef\xbb\xbf" _UTF8_BOM = b"\xef\xbb\xbf"
_BOM_SUFFIXES = {".ps1", ".txt"} _BOM_SUFFIXES = {".ps1", ".txt"}
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,128}$")
_lock = Lock() TTL_SECONDS = 600
_entries: dict[str, tuple[bytes, float]] = {} BUNDLE_DIR = Path("/tmp/sac-rdp-bundles")
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)
def _bundle_file_bytes(path: Path) -> bytes: 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() 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: def register_rdp_bundle_zip(zip_bytes: bytes) -> str:
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
expires_at = time.time() + TTL_SECONDS BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
with _lock: _prune_expired_bundles()
_prune_expired(time.time()) (BUNDLE_DIR / f"{token}.zip").write_bytes(zip_bytes)
_entries[token] = (zip_bytes, expires_at)
return token return token
def get_rdp_bundle_zip(token: str) -> bytes | None: def get_rdp_bundle_zip(token: str) -> bytes | None:
text = (token or "").strip() text = (token or "").strip()
if not text: if not _TOKEN_RE.fullmatch(text):
return None return None
now = time.time() path = BUNDLE_DIR / f"{text}.zip"
with _lock: if not path.is_file():
_prune_expired(now) return None
entry = _entries.get(text) if time.time() - path.stat().st_mtime > TTL_SECONDS:
if entry is None: path.unlink(missing_ok=True)
return None return None
data, expires_at = entry return path.read_bytes()
if expires_at <= now:
_entries.pop(text, None)
return None
return data
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.20.17" APP_VERSION = "0.20.18"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+4 -2
View File
@@ -70,7 +70,8 @@ def test_custom_deploy_script_escapes_single_quotes():
assert "O''Brien" in script 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 = tmp_path / "repo"
repo.mkdir() repo.mkdir()
(repo / "Deploy-LoginMonitor.ps1").write_text("# test", encoding="utf-8") (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) 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 = tmp_path / "repo"
repo.mkdir() repo.mkdir()
for name in RDP_BUNDLE_REQUIRED: for name in RDP_BUNDLE_REQUIRED:
+1 -1
View File
@@ -104,7 +104,7 @@ print('db: OK')
\" \"
" || die "PostgreSQL: проверьте DATABASE_URL в ${CONFIG_FILE} и systemctl status postgresql" " || 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 restart "${SERVICE_NAME}"
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active" systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active"
+1 -1
View File
@@ -13,7 +13,7 @@ WorkingDirectory=/opt/security-alert-center/backend
# Конфиг читает само приложение (pydantic), не systemd — иначе ломаются пароли с #, $ и т.д. # Конфиг читает само приложение (pydantic), не systemd — иначе ломаются пароли с #, $ и т.д.
Environment=SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env Environment=SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env
Environment=PYTHONPATH=/opt/security-alert-center/backend 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 Restart=on-failure
RestartSec=5 RestartSec=5
TimeoutStopSec=20 TimeoutStopSec=20
+20 -5
View File
@@ -62,20 +62,20 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
clearToken(); clearToken();
redirectToErrorPage(401); redirectToErrorPage(401);
} }
throw new ApiError(parseApiError(text) || "Unauthorized", 401); throw new ApiError(parseApiError(text, 401) || "Unauthorized", 401);
} }
if (res.status === 403) { if (res.status === 403) {
if (hadToken) { if (hadToken) {
redirectToErrorPage(403); redirectToErrorPage(403);
} }
throw new ApiError(parseApiError(text) || "Forbidden", 403); throw new ApiError(parseApiError(text, 403) || "Forbidden", 403);
} }
if (res.status === 429) { if (res.status === 429) {
redirectToErrorPage(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) { 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) { if (!text) {
return undefined as T; return undefined as T;
@@ -99,8 +99,23 @@ export function fetchMe(): Promise<MeResponse> {
return apiFetch<MeResponse>("/api/v1/auth/me"); return apiFetch<MeResponse>("/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 (/<title>\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) return "";
if (text.trimStart().startsWith("<")) {
return status ? `Ошибка HTTP ${status}` : "Ошибка сервера";
}
try { try {
const data = JSON.parse(text) as { detail?: string | Array<{ msg?: string }> }; const data = JSON.parse(text) as { detail?: string | Array<{ msg?: string }> };
if (typeof data.detail === "string") return data.detail; if (typeof data.detail === "string") return data.detail;
+1 -1
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center"; 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}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+7 -1
View File
@@ -101,6 +101,7 @@ import { useRoute, useRouter } from "vue-router";
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream"; import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
import { import {
apiFetch, apiFetch,
ApiError,
fetchHost, fetchHost,
fetchRecentEvents, fetchRecentEvents,
type HostDetail, type HostDetail,
@@ -304,7 +305,7 @@ const sortedItems = computed(() => {
return items; return items;
}); });
async function loadHosts() { async function loadHosts(retry = 0) {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
try { try {
@@ -314,6 +315,11 @@ async function loadHosts() {
if (agentStatusFilter.value) params.set("agent_status", agentStatusFilter.value); if (agentStatusFilter.value) params.set("agent_status", agentStatusFilter.value);
data.value = await apiFetch<HostListResponse>(`/api/v1/hosts?${params}`); data.value = await apiFetch<HostListResponse>(`/api/v1/hosts?${params}`);
} catch (e) { } 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 : "Ошибка"; error.value = e instanceof Error ? e.message : "Ошибка";
} finally { } finally {
loading.value = false; loading.value = false;