fix: friendly 502 handling, 2 uvicorn workers, file bundle cache (0.20.18)
This commit is contained in:
@@ -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)
|
|
||||||
entry = _entries.get(text)
|
|
||||||
if entry is None:
|
|
||||||
return None
|
return None
|
||||||
data, expires_at = entry
|
if time.time() - path.stat().st_mtime > TTL_SECONDS:
|
||||||
if expires_at <= now:
|
path.unlink(missing_ok=True)
|
||||||
_entries.pop(text, None)
|
|
||||||
return None
|
return None
|
||||||
return data
|
return path.read_bytes()
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,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}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user