fix: hide ????????? after session terminate (0.3.8)

Persist session_terminated in event details, expose in API, and update Events/Dashboard UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-24 14:45:33 +10:00
parent 8b3e43f30d
commit 0601aafd2d
13 changed files with 86 additions and 18 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) | | **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
**Версия:** `0.3.7` · **Деплой:** `sudo /opt/sac-deploy.sh` **Версия:** `0.3.8` · **Деплой:** `sudo /opt/sac-deploy.sh`
## Возможности ## Возможности
+1 -1
View File
@@ -13,7 +13,7 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre
| **security-alert-center** | SAC server (Ubuntu 24.04) | | **security-alert-center** | SAC server (Ubuntu 24.04) |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client |
**Version:** `0.3.7` · **Deploy:** `sudo /opt/sac-deploy.sh` **Version:** `0.3.8` · **Deploy:** `sudo /opt/sac-deploy.sh`
## Features ## Features
+9 -9
View File
@@ -22,7 +22,9 @@ from app.services.agent_commands import (
from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
from app.services.host_sessions import ( from app.services.host_sessions import (
event_session_id, event_session_id,
event_session_terminated,
event_supports_session_terminate, event_supports_session_terminate,
mark_event_session_terminated,
terminate_session_for_event, terminate_session_for_event,
) )
from app.services.linux_admin_settings import get_effective_linux_admin_config from app.services.linux_admin_settings import get_effective_linux_admin_config
@@ -254,6 +256,8 @@ def post_event_terminate_session(
raise HTTPException(status_code=404, detail="Event not found") raise HTTPException(status_code=404, detail="Event not found")
if not event_supports_session_terminate(event): if not event_supports_session_terminate(event):
raise HTTPException(status_code=400, detail="Event type does not support session terminate") raise HTTPException(status_code=400, detail="Event type does not support session terminate")
if event_session_terminated(event):
raise HTTPException(status_code=409, detail="Session already terminated for this event")
linux_cfg = get_effective_linux_admin_config(db) linux_cfg = get_effective_linux_admin_config(db)
win_cfg = get_effective_win_admin_config(db) win_cfg = get_effective_win_admin_config(db)
@@ -277,21 +281,17 @@ def post_event_terminate_session(
except SshHostTargetMissingError as exc: except SshHostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
if hasattr(result, "exit_code"): response = EventSessionTerminateResponse(
return EventSessionTerminateResponse(
ok=result.ok,
message=result.message,
target=getattr(result, "target", None),
stdout=getattr(result, "stdout", None) or None,
stderr=getattr(result, "stderr", None) or None,
)
return EventSessionTerminateResponse(
ok=result.ok, ok=result.ok,
message=result.message, message=result.message,
target=getattr(result, "target", None), target=getattr(result, "target", None),
stdout=getattr(result, "stdout", None) or None, stdout=getattr(result, "stdout", None) or None,
stderr=getattr(result, "stderr", None) or None, stderr=getattr(result, "stderr", None) or None,
) )
if result.ok:
mark_event_session_terminated(event, by_username=user.username)
db.commit()
return response
@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse) @router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse)
+1
View File
@@ -83,6 +83,7 @@ class EventSummary(BaseModel):
rdg_flap_qwinsta_event_id: int | None = None rdg_flap_qwinsta_event_id: int | None = None
rdg_access_path: str | None = None rdg_access_path: str | None = None
rdg_qwinsta_enabled: bool = False rdg_qwinsta_enabled: bool = False
session_terminated: bool = False
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+2
View File
@@ -3,6 +3,7 @@ from sqlalchemy.orm import Session
from app.models.event import Event from app.models.event import Event
from app.schemas.list_models import EventSummary from app.schemas.list_models import EventSummary
from app.services.event_actor_user import extract_event_actor_user from app.services.event_actor_user import extract_event_actor_user
from app.services.host_sessions import event_session_terminated
from app.services.rdg_display import build_rdg_display from app.services.rdg_display import build_rdg_display
from app.services.rdg_session_flap import resolve_rdg_flap_summary from app.services.rdg_session_flap import resolve_rdg_flap_summary
@@ -48,4 +49,5 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id, rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
rdg_access_path=rdg_access_path, rdg_access_path=rdg_access_path,
rdg_qwinsta_enabled=rdg_qwinsta_enabled, rdg_qwinsta_enabled=rdg_qwinsta_enabled,
session_terminated=event_session_terminated(event),
) )
+20
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import json import json
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone
from app.models import Event, Host from app.models import Event, Host
from app.services.event_actor_user import extract_event_actor_user from app.services.event_actor_user import extract_event_actor_user
@@ -37,6 +38,9 @@ SESSION_EVENT_TYPES_LINUX = frozenset(
) )
SESSION_EVENT_TYPES_WINDOWS = frozenset({"rdp.login.success"}) SESSION_EVENT_TYPES_WINDOWS = frozenset({"rdp.login.success"})
SESSION_TERMINATED_AT_KEY = "session_terminated_at"
SESSION_TERMINATED_BY_KEY = "session_terminated_by"
_LOGIND_SESSION_ID_RE = re.compile(r"^\d+$|^c\d+$", re.IGNORECASE) _LOGIND_SESSION_ID_RE = re.compile(r"^\d+$|^c\d+$", re.IGNORECASE)
_LOGIND_USER_RE = re.compile(r"^[a-z_][a-z0-9._-]*$", re.IGNORECASE) _LOGIND_USER_RE = re.compile(r"^[a-z_][a-z0-9._-]*$", re.IGNORECASE)
_LOGIND_STATES = frozenset( _LOGIND_STATES = frozenset(
@@ -77,6 +81,22 @@ def _event_login_user(event: Event) -> str:
return str(_details_dict(event).get("user") or "").strip() return str(_details_dict(event).get("user") or "").strip()
def event_session_terminated(event: Event) -> bool:
details = _details_dict(event)
if details.get("session_terminated") is True:
return True
at = details.get(SESSION_TERMINATED_AT_KEY)
return at is not None and str(at).strip() != ""
def mark_event_session_terminated(event: Event, *, by_username: str | None = None) -> None:
details = dict(_details_dict(event))
details[SESSION_TERMINATED_AT_KEY] = datetime.now(timezone.utc).isoformat()
if by_username and by_username.strip():
details[SESSION_TERMINATED_BY_KEY] = by_username.strip()
event.details = details
def event_session_id(event: Event) -> str | None: def event_session_id(event: Event) -> str | None:
details = _details_dict(event) details = _details_dict(event)
for key in ("session_id", "sid"): for key in ("session_id", "sid"):
+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.3.7" APP_VERSION = "0.3.8"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+17
View File
@@ -5,8 +5,10 @@ from types import SimpleNamespace
from app.services.host_sessions import ( from app.services.host_sessions import (
HostSessionRow, HostSessionRow,
_event_login_user, _event_login_user,
event_session_terminated,
event_supports_session_terminate, event_supports_session_terminate,
filter_logind_session_rows, filter_logind_session_rows,
mark_event_session_terminated,
parse_loginctl_sessions, parse_loginctl_sessions,
parse_loginctl_sessions_json, parse_loginctl_sessions_json,
parse_qwinsta_sessions, parse_qwinsta_sessions,
@@ -130,3 +132,18 @@ def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch):
win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"), win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"),
) )
assert result.ok is True assert result.ok is True
def test_event_session_terminated_flag():
event = SimpleNamespace(
type="rdp.login.success",
details={"session_terminated_at": "2026-06-24T10:00:00+00:00"},
)
assert event_session_terminated(event) is True
fresh = SimpleNamespace(type="rdp.login.success", details={"user": "alice"})
assert event_session_terminated(fresh) is False
mark_event_session_terminated(fresh, by_username="admin")
assert event_session_terminated(fresh) is True
assert fresh.details["session_terminated_by"] == "admin"
+1
View File
@@ -189,6 +189,7 @@ export interface EventSummary {
rdg_flap_qwinsta_event_id?: number | null; rdg_flap_qwinsta_event_id?: number | null;
rdg_access_path?: string | null; rdg_access_path?: string | null;
rdg_qwinsta_enabled?: boolean; rdg_qwinsta_enabled?: boolean;
session_terminated?: boolean;
} }
export interface AgentCommandResponse { export interface AgentCommandResponse {
+16
View File
@@ -14,3 +14,19 @@ export function eventSupportsSessionTerminate(event: Pick<EventSummary, "type">)
WINDOWS_SESSION_EVENT_TYPES.has(event.type) WINDOWS_SESSION_EVENT_TYPES.has(event.type)
); );
} }
export function showEventSessionTerminateButton(
event: Pick<EventSummary, "type" | "session_terminated">,
): boolean {
return eventSupportsSessionTerminate(event) && !event.session_terminated;
}
export function markEventSessionTerminatedInList(
items: EventSummary[] | undefined,
eventId: number,
): void {
const item = items?.find((row) => row.id === eventId);
if (item) {
item.session_terminated = true;
}
}
+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.3.7"; export const APP_VERSION = "0.3.8";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+8 -2
View File
@@ -299,7 +299,7 @@
Обрыв RDS Обрыв RDS
</button> </button>
<button <button
v-if="eventSupportsSessionTerminate(e)" v-if="showEventSessionTerminateButton(e)"
type="button" type="button"
class="secondary dash-qwinsta-btn" class="secondary dash-qwinsta-btn"
:disabled="sessionTerminateLoadingId === e.id" :disabled="sessionTerminateLoadingId === e.id"
@@ -342,6 +342,7 @@ import { onMounted, onUnmounted, ref } from "vue";
import { import {
apiFetch, apiFetch,
ApiError,
getToken, getToken,
postEventTerminateSession, postEventTerminateSession,
type DashboardSummary, type DashboardSummary,
@@ -353,7 +354,7 @@ import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta"; import { useRdgQwinsta } from "../composables/useRdgQwinsta";
import { formatServerName } from "../utils/hostDisplay"; import { formatServerName } from "../utils/hostDisplay";
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap"; import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
import { eventSupportsSessionTerminate } from "../utils/sessionActions"; import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta(); const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
const sessionTerminateLoadingId = ref<number | null>(null); const sessionTerminateLoadingId = ref<number | null>(null);
@@ -386,8 +387,13 @@ async function runSessionTerminate(event: EventSummary) {
const res = await postEventTerminateSession(event.id); const res = await postEventTerminateSession(event.id);
if (!res.ok) { if (!res.ok) {
window.alert(res.message); window.alert(res.message);
} else {
markEventSessionTerminatedInList(data.value?.recent_events, event.id);
} }
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 409) {
markEventSessionTerminatedInList(data.value?.recent_events, event.id);
}
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии"); window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
} finally { } finally {
sessionTerminateLoadingId.value = null; sessionTerminateLoadingId.value = null;
+8 -3
View File
@@ -70,7 +70,7 @@
Обрыв RDS Обрыв RDS
</button> </button>
<button <button
v-if="eventSupportsSessionTerminate(e)" v-if="showEventSessionTerminateButton(e)"
type="button" type="button"
class="secondary events-qwinsta-btn" class="secondary events-qwinsta-btn"
:disabled="sessionTerminateLoadingId === e.id" :disabled="sessionTerminateLoadingId === e.id"
@@ -106,7 +106,7 @@
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream"; import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
import { apiFetch, postEventTerminateSession, type EventListResponse, type EventSummary } from "../api"; import { apiFetch, ApiError, postEventTerminateSession, type EventListResponse, type EventSummary } from "../api";
import RdgAccessBadge from "../components/RdgAccessBadge.vue"; import RdgAccessBadge from "../components/RdgAccessBadge.vue";
import RdgFlapBadge from "../components/RdgFlapBadge.vue"; import RdgFlapBadge from "../components/RdgFlapBadge.vue";
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue"; import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
@@ -120,7 +120,7 @@ import {
type EventsListState, type EventsListState,
} from "../utils/eventsListQuery"; } from "../utils/eventsListQuery";
import { formatServerName } from "../utils/hostDisplay"; import { formatServerName } from "../utils/hostDisplay";
import { eventSupportsSessionTerminate } from "../utils/sessionActions"; import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -227,8 +227,13 @@ async function runSessionTerminate(event: EventSummary) {
const res = await postEventTerminateSession(event.id); const res = await postEventTerminateSession(event.id);
if (!res.ok) { if (!res.ok) {
window.alert(res.message); window.alert(res.message);
} else {
markEventSessionTerminatedInList(data.value?.items, event.id);
} }
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 409) {
markEventSessionTerminatedInList(data.value?.items, event.id);
}
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии"); window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
} finally { } finally {
sessionTerminateLoadingId.value = null; sessionTerminateLoadingId.value = null;