Compare commits

..

1 Commits

Author SHA1 Message Date
PapaTramp ed4e78f6c3 chore(home): mirror from kalinamall (9883e6a) with papatramp URLs 2026-07-14 20:43:52 +10:00
76 changed files with 2569 additions and 196 deletions
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts must use LF — CRLF in shebang breaks systemd (status=203/EXEC).
*.sh text eol=lf
+6 -6
View File
@@ -1,4 +1,4 @@
# Security Alert Center (SAC) # Security Alert Center (SAC)
Центральная платформа: приём событий от агентов, корреляция, UI, оповещения, управление хостами. Центральная платформа: приём событий от агентов, корреляция, UI, оповещения, управление хостами.
@@ -8,12 +8,12 @@
| Репозиторий | Назначение | | Репозиторий | Назначение |
|-------------|------------| |-------------|------------|
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux-агент | | [ssh-monitor](https://git.papatramp.ru/PapaTramp/ssh-monitor) | Linux-агент |
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows-агент | | [RDP-login-monitor](https://git.papatramp.ru/PapaTramp/RDP-login-monitor) | Windows-агент |
| **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.papatramp.ru/PapaTramp/seaca) | Android-клиент |
**Версия:** `0.5.0` · **Деплой:** `sudo /opt/sac-deploy.sh` **Версия:** `0.5.16` · **Деплой:** `sudo /opt/sac-deploy.sh`
## Возможности ## Возможности
@@ -40,7 +40,7 @@
## Быстрый старт ## Быстрый старт
```bash ```bash
git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
# deploy/env.native.example → config/sac-api.env (JWT_SECRET, CORS_ORIGINS, SAC_SECURITY_ENFORCE) # deploy/env.native.example → config/sac-api.env (JWT_SECRET, CORS_ORIGINS, SAC_SECURITY_ENFORCE)
cd /opt/security-alert-center/backend && python3 -m venv .venv cd /opt/security-alert-center/backend && python3 -m venv .venv
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head .venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
+6 -6
View File
@@ -1,4 +1,4 @@
# Security Alert Center (SAC) # Security Alert Center (SAC)
Self-hosted hub for security events from Linux and Windows agents: ingest, correlation, UI, notifications, host management. Self-hosted hub for security events from Linux and Windows agents: ingest, correlation, UI, notifications, host management.
@@ -8,12 +8,12 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre
| Repository | Role | | Repository | Role |
|------------|------| |------------|------|
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux agent | | [ssh-monitor](https://git.papatramp.ru/PapaTramp/ssh-monitor) | Linux agent |
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows agent | | [RDP-login-monitor](https://git.papatramp.ru/PapaTramp/RDP-login-monitor) | Windows agent |
| **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.papatramp.ru/PapaTramp/seaca) | Android client |
**Version:** `0.5.0` · **Deploy:** `sudo /opt/sac-deploy.sh` **Version:** `0.5.16` · **Deploy:** `sudo /opt/sac-deploy.sh`
## Features ## Features
@@ -39,7 +39,7 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre
## Quick start ## Quick start
```bash ```bash
git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
# deploy/env.native.example → config/sac-api.env # deploy/env.native.example → config/sac-api.env
cd /opt/security-alert-center/backend && python3 -m venv .venv cd /opt/security-alert-center/backend && python3 -m venv .venv
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head .venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
@@ -0,0 +1,25 @@
"""per-host management credentials (SSH / WinRM override)
Revision ID: 027_host_mgmt_credentials
Revises: 026_host_silence_dedupe
Create Date: 2026-07-14
"""
from alembic import op
import sqlalchemy as sa
revision = "027_host_mgmt_credentials"
down_revision = "026_host_silence_dedupe"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("hosts", sa.Column("mgmt_user", sa.String(length=256), nullable=True))
op.add_column("hosts", sa.Column("mgmt_password", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("hosts", "mgmt_password")
op.drop_column("hosts", "mgmt_user")
+16 -6
View File
@@ -28,12 +28,12 @@ from app.services.host_sessions import (
mark_event_session_terminated, 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_for_host
from app.services.ssh_connect import ( from app.services.ssh_connect import (
HostNotLinuxError as SshHostNotLinuxError, HostNotLinuxError as SshHostNotLinuxError,
HostTargetMissingError as SshHostTargetMissingError, HostTargetMissingError as SshHostTargetMissingError,
) )
from app.services.win_admin_settings import get_effective_win_admin_config from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
from app.services.ingest import ingest_event from app.services.ingest import ingest_event
from app.services.event_summary import event_to_summary from app.services.event_summary import event_to_summary
@@ -52,7 +52,9 @@ from app.services.notify_dispatch import (
notify_lifecycle, notify_lifecycle,
notify_problem, notify_problem,
notify_rdg_connection, notify_rdg_connection,
schedule_notify_auth_login,
schedule_notify_daily_report, schedule_notify_daily_report,
schedule_notify_lifecycle,
) )
router = APIRouter(prefix="/events", tags=["events"]) router = APIRouter(prefix="/events", tags=["events"])
@@ -100,15 +102,17 @@ def post_event(
problem = None problem = None
problem_created = False problem_created = False
deferred_daily_report_id: int | None = None deferred_daily_report_id: int | None = None
deferred_lifecycle_id: int | None = None
deferred_auth_login_id: int | None = None
if created: if created:
problem, problem_created = maybe_create_problem(db, event) problem, problem_created = maybe_create_problem(db, event)
maybe_auto_disconnect_stuck_rdp_session(db, event) maybe_auto_disconnect_stuck_rdp_session(db, event)
if event.type in DAILY_REPORT_EVENT_TYPES: if event.type in DAILY_REPORT_EVENT_TYPES:
deferred_daily_report_id = event.id deferred_daily_report_id = event.id
elif event.type == LIFECYCLE_EVENT_TYPE: elif event.type == LIFECYCLE_EVENT_TYPE:
notify_lifecycle(event, db=db) deferred_lifecycle_id = event.id
elif event.type in AUTH_LOGIN_SUCCESS_TYPES: elif event.type in AUTH_LOGIN_SUCCESS_TYPES:
notify_auth_login(event, db=db) deferred_auth_login_id = event.id
elif event.type in RDG_CONNECTION_TYPES: elif event.type in RDG_CONNECTION_TYPES:
notify_rdg_connection(event, db=db) notify_rdg_connection(event, db=db)
else: else:
@@ -122,6 +126,10 @@ def post_event(
if deferred_daily_report_id is not None: if deferred_daily_report_id is not None:
background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id) background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id)
if deferred_lifecycle_id is not None:
background_tasks.add_task(schedule_notify_lifecycle, deferred_lifecycle_id)
if deferred_auth_login_id is not None:
background_tasks.add_task(schedule_notify_auth_login, deferred_auth_login_id)
body = _ingest_response(event, created=created) body = _ingest_response(event, created=created)
if problem is not None: if problem is not None:
@@ -276,8 +284,10 @@ def post_event_terminate_session(
if event_session_terminated(event, db=db): if event_session_terminated(event, db=db):
raise HTTPException(status_code=409, detail="Session already terminated for this event") raise HTTPException(status_code=409, detail="Session already terminated for this event")
linux_cfg = get_effective_linux_admin_config(db) if event.host is None:
win_cfg = get_effective_win_admin_config(db) raise HTTPException(status_code=400, detail="Event has no host")
linux_cfg = get_effective_linux_admin_for_host(db, event.host)
win_cfg = get_effective_win_admin_for_host(db, event.host)
sid = (body.session_id if body else None) or event_session_id(event) sid = (body.session_id if body else None) or event_session_id(event)
try: try:
+111 -16
View File
@@ -46,8 +46,12 @@ from app.services.host_sessions import (
terminate_windows_session, terminate_windows_session,
) )
from app.services.host_delete import delete_host_and_related from app.services.host_delete import delete_host_and_related
from app.services.linux_admin_settings import get_effective_linux_admin_config from app.services.host_mgmt_credentials import (
from app.services.win_admin_settings import get_effective_win_admin_config get_host_mgmt_access_view,
upsert_host_mgmt_credentials,
)
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.winrm_connect import ( from app.services.winrm_connect import (
HostNotWindowsError, HostNotWindowsError,
HostTargetMissingError, HostTargetMissingError,
@@ -299,6 +303,23 @@ class HostAgentConfigResponse(BaseModel):
settings: dict[str, object] settings: dict[str, object]
class HostMgmtAccessResponse(BaseModel):
host_id: int
has_override: bool
user: str | None = None
password_set: bool = False
password_hint: str | None = None
effective_source: str
effective_configured: bool
effective_user: str | None = None
class HostMgmtAccessUpdate(BaseModel):
user: str | None = None
password: str | None = None
clear: bool = False
class HostRemoteActionStartResponse(BaseModel): class HostRemoteActionStartResponse(BaseModel):
status: str status: str
host_id: int host_id: int
@@ -469,9 +490,12 @@ def test_host_winrm(
if host is None: if host is None:
raise HTTPException(status_code=404, detail="Host not found") raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_win_admin_config(db) cfg = get_effective_win_admin_for_host(db, host)
if not cfg.configured: if not cfg.configured:
raise HTTPException(status_code=400, detail="Windows domain admin is not configured") raise HTTPException(
status_code=400,
detail="Windows admin is not configured (host override or Settings → Windows)",
)
try: try:
targets = iter_winrm_targets(host) targets = iter_winrm_targets(host)
@@ -532,9 +556,12 @@ def test_host_ssh(
if host is None: if host is None:
raise HTTPException(status_code=404, detail="Host not found") raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_linux_admin_config(db) cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured: if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection) response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
_set_ssh_admin_status(host, response.ok) _set_ssh_admin_status(host, response.ok)
@@ -556,9 +583,12 @@ def update_host_agent_via_ssh(
if host is None: if host is None:
raise HTTPException(status_code=404, detail="Host not found") raise HTTPException(status_code=404, detail="Host not found")
cfg = get_effective_linux_admin_config(db) cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured: if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
title = "Обновление ssh-monitor (SSH)" title = "Обновление ssh-monitor (SSH)"
try: try:
@@ -567,6 +597,7 @@ def update_host_agent_via_ssh(
host, host,
title=title, title=title,
runner=run_ssh_monitor_update_action, runner=run_ssh_monitor_update_action,
poll_remote_log=True,
) )
except RemoteActionAlreadyRunningError as exc: except RemoteActionAlreadyRunningError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
@@ -652,6 +683,7 @@ def get_host_remote_job(
host = db.get(Host, host_id) host = db.get(Host, host_id)
if host is None: if host is None:
raise HTTPException(status_code=404, detail="Host not found") raise HTTPException(status_code=404, detail="Host not found")
db.refresh(host)
return HostRemoteActionJobResponse(**get_remote_action_status(host)) return HostRemoteActionJobResponse(**get_remote_action_status(host))
@@ -684,9 +716,12 @@ def list_host_sessions(
from app.services.winrm_connect import is_windows_host from app.services.winrm_connect import is_windows_host
if is_linux_host(host): if is_linux_host(host):
cfg = get_effective_linux_admin_config(db) cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured: if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
try: try:
sessions, result = list_linux_sessions(host, cfg) sessions, result = list_linux_sessions(host, cfg)
except SshHostNotLinuxError as exc: except SshHostNotLinuxError as exc:
@@ -701,9 +736,12 @@ def list_host_sessions(
) )
if is_windows_host(host): if is_windows_host(host):
cfg = get_effective_win_admin_config(db) cfg = get_effective_win_admin_for_host(db, host)
if not cfg.configured: if not cfg.configured:
raise HTTPException(status_code=400, detail="Windows domain admin is not configured") raise HTTPException(
status_code=400,
detail="Windows admin is not configured (host override or Settings → Windows)",
)
try: try:
sessions, result = list_windows_sessions(host, cfg) sessions, result = list_windows_sessions(host, cfg)
except HostNotWindowsError as exc: except HostNotWindowsError as exc:
@@ -741,9 +779,12 @@ def terminate_host_session(
raise HTTPException(status_code=422, detail="session_id is required") raise HTTPException(status_code=422, detail="session_id is required")
if is_linux_host(host): if is_linux_host(host):
cfg = get_effective_linux_admin_config(db) cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured: if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured") raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
try: try:
result = terminate_linux_session(host, cfg, sid) result = terminate_linux_session(host, cfg, sid)
except SshHostNotLinuxError as exc: except SshHostNotLinuxError as exc:
@@ -759,9 +800,12 @@ def terminate_host_session(
) )
if is_windows_host(host): if is_windows_host(host):
cfg = get_effective_win_admin_config(db) cfg = get_effective_win_admin_for_host(db, host)
if not cfg.configured: if not cfg.configured:
raise HTTPException(status_code=400, detail="Windows domain admin is not configured") raise HTTPException(
status_code=400,
detail="Windows admin is not configured (host override or Settings → Windows)",
)
try: try:
result = terminate_windows_session(host, cfg, sid) result = terminate_windows_session(host, cfg, sid)
except HostNotWindowsError as exc: except HostNotWindowsError as exc:
@@ -801,6 +845,57 @@ def patch_host_agent_config(
) )
@router.get("/{host_id}/access", response_model=HostMgmtAccessResponse)
def get_host_access(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostMgmtAccessResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
view = get_host_mgmt_access_view(db, host)
return HostMgmtAccessResponse(
host_id=view.host_id,
has_override=view.has_override,
user=view.user,
password_set=view.password_set,
password_hint=view.password_hint,
effective_source=view.effective_source,
effective_configured=view.effective_configured,
effective_user=view.effective_user,
)
@router.put("/{host_id}/access", response_model=HostMgmtAccessResponse)
def put_host_access(
host_id: int,
body: HostMgmtAccessUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostMgmtAccessResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
view = upsert_host_mgmt_credentials(
db,
host,
user=body.user,
password=body.password,
clear=body.clear,
)
return HostMgmtAccessResponse(
host_id=view.host_id,
has_override=view.has_override,
user=view.user,
password_set=view.password_set,
password_hint=view.password_hint,
effective_source=view.effective_source,
effective_configured=view.effective_configured,
effective_user=view.effective_user,
)
@router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse) @router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
def get_host_agent_config( def get_host_agent_config(
host_id: int, host_id: int,
+4 -3
View File
@@ -1,4 +1,4 @@
import os import os
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
@@ -36,6 +36,7 @@ class Settings(BaseSettings):
database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac" database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac"
sac_db_pool_size: int = 15 sac_db_pool_size: int = 15
sac_db_max_overflow: int = 25 sac_db_max_overflow: int = 25
sac_uvicorn_workers: int = 4
sac_public_url: str = "http://localhost:8000" sac_public_url: str = "http://localhost:8000"
# URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL. # URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL.
sac_agent_bundle_base_url: str = "" sac_agent_bundle_base_url: str = ""
@@ -137,8 +138,8 @@ class Settings(BaseSettings):
sac_agent_min_rdp_version: str = "" sac_agent_min_rdp_version: str = ""
sac_agent_min_ssh_version: str = "" sac_agent_min_ssh_version: str = ""
sac_win_agent_update_script: str = "" sac_win_agent_update_script: str = ""
sac_agent_rdp_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git" sac_agent_rdp_git_repo_url: str = "https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
sac_agent_ssh_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git" sac_agent_ssh_git_repo_url: str = "https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
sac_agent_git_branch: str = "main" sac_agent_git_branch: str = "main"
sac_agent_git_cache_dir: str = "/opt/security-alert-center/cache/agent-repos" sac_agent_git_cache_dir: str = "/opt/security-alert-center/cache/agent-repos"
sac_agent_git_cache_ttl_minutes: int = 30 sac_agent_git_cache_ttl_minutes: int = 30
+1
View File
@@ -23,6 +23,7 @@ DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
# RDP / Windows # RDP / Windows
"rdp.login.success": "info", "rdp.login.success": "info",
"rdp.login.failed": "warning", "rdp.login.failed": "warning",
"rdp.session.logoff": "info",
"rdp.shadow.control.started": "warning", "rdp.shadow.control.started": "warning",
"rdp.shadow.control.stopped": "info", "rdp.shadow.control.stopped": "info",
"rdp.shadow.control.permission": "warning", "rdp.shadow.control.permission": "warning",
+3
View File
@@ -26,6 +26,9 @@ class Host(Base):
use_sac_mode: Mapped[str | None] = mapped_column(String(32)) use_sac_mode: Mapped[str | None] = mapped_column(String(32))
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True) ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Optional override for SSH/WinRM (home PCs, unique local admin). Empty → global Settings.
mgmt_user: Mapped[str | None] = mapped_column(String(256))
mgmt_password: Mapped[str | None] = mapped_column(Text)
pending_agent_update: Mapped[bool] = mapped_column(default=False, server_default="false") pending_agent_update: Mapped[bool] = mapped_column(default=False, server_default="false")
pending_update_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) pending_update_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
pending_update_target_version: Mapped[str | None] = mapped_column(String(64)) pending_update_target_version: Mapped[str | None] = mapped_column(String(64))
+1
View File
@@ -78,6 +78,7 @@ class EventSummary(BaseModel):
title: str title: str
summary: str summary: str
actor_user: str | None = None actor_user: str | None = None
session_duration_sec: int | None = None
rdg_flap: bool = False rdg_flap: bool = False
rdg_flap_pair_event_id: int | None = None rdg_flap_pair_event_id: int | None = None
rdg_flap_qwinsta_event_id: int | None = None rdg_flap_qwinsta_event_id: int | None = None
+17 -6
View File
@@ -7,17 +7,22 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import AgentCommand, Host from app.models import AgentCommand, Host
from app.services.win_admin_settings import get_effective_win_admin_config from app.services.win_admin_settings import (
get_effective_win_admin_config,
get_effective_win_admin_for_host,
)
def _win_admin_configured() -> bool: def _win_admin_configured() -> bool:
return get_effective_win_admin_config().configured return get_effective_win_admin_config().configured
def win_admin_run_as() -> dict[str, str] | None: def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dict[str, str] | None:
cfg = get_effective_win_admin_config() if host is not None and db is not None:
cfg = get_effective_win_admin_for_host(db, host)
else:
cfg = get_effective_win_admin_config(db)
if not cfg.configured: if not cfg.configured:
return None return None
return { return {
@@ -26,7 +31,13 @@ def win_admin_run_as() -> dict[str, str] | None:
} }
def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict: def command_to_dict(
cmd: AgentCommand,
*,
include_run_as: bool = False,
db: Session | None = None,
host: Host | None = None,
) -> dict:
out = { out = {
"id": cmd.command_uuid, "id": cmd.command_uuid,
"type": cmd.command_type, "type": cmd.command_type,
@@ -38,7 +49,7 @@ def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict:
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None, "completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
} }
if include_run_as and cmd.status == "pending": if include_run_as and cmd.status == "pending":
run_as = win_admin_run_as() run_as = win_admin_run_as(db, host)
if run_as: if run_as:
out["run_as"] = run_as out["run_as"] = run_as
return out return out
+1 -1
View File
@@ -27,5 +27,5 @@ def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]:
"config_revision": int(host.agent_config_revision or 0), "config_revision": int(host.agent_config_revision or 0),
"config": config_payload, "config": config_payload,
"update": update_block, "update": update_block,
"commands": [command_to_dict(c, include_run_as=True) for c in pending], "commands": [command_to_dict(c, include_run_as=True, db=db, host=host) for c in pending],
} }
+4 -4
View File
@@ -20,7 +20,7 @@ from app.services.agent_version import (
host_version_outdated, host_version_outdated,
latest_agent_versions_by_product, latest_agent_versions_by_product,
) )
from app.services.linux_admin_settings import get_effective_linux_admin_config from app.services.linux_admin_settings import get_effective_linux_admin_for_host
from app.services.ssh_connect import ( from app.services.ssh_connect import (
HostNotLinuxError, HostNotLinuxError,
HostTargetMissingError, HostTargetMissingError,
@@ -28,7 +28,7 @@ from app.services.ssh_connect import (
iter_ssh_targets, iter_ssh_targets,
run_ssh_monitor_update, run_ssh_monitor_update,
) )
from app.services.win_admin_settings import get_effective_win_admin_config from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.winrm_connect import ( from app.services.winrm_connect import (
HostNotWindowsError, HostNotWindowsError,
HostTargetMissingError as WinRmHostTargetMissingError, HostTargetMissingError as WinRmHostTargetMissingError,
@@ -249,7 +249,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbac
product = (host.product or "").strip() product = (host.product or "").strip()
if product == PRODUCT_SSH or is_linux_host(host): if product == PRODUCT_SSH or is_linux_host(host):
linux_cfg = get_effective_linux_admin_config(db) linux_cfg = get_effective_linux_admin_for_host(db, host)
agent_cfg = get_effective_agent_update_config(db) agent_cfg = get_effective_agent_update_config(db)
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
branch = (agent_cfg.git_branch or "main").strip() or "main" branch = (agent_cfg.git_branch or "main").strip() or "main"
@@ -260,7 +260,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbac
git_branch=branch, git_branch=branch,
) )
elif product == PRODUCT_RDP or is_windows_host(host): elif product == PRODUCT_RDP or is_windows_host(host):
win_cfg = get_effective_win_admin_config(db) win_cfg = get_effective_win_admin_for_host(db, host)
result = run_windows_agent_update_fallback( result = run_windows_agent_update_fallback(
host, host,
win_cfg, win_cfg,
+1
View File
@@ -15,6 +15,7 @@ ACTOR_USER_EVENT_TYPES: frozenset[str] = frozenset(
"session.logind.failed", "session.logind.failed",
"rdp.login.success", "rdp.login.success",
"rdp.login.failed", "rdp.login.failed",
"rdp.session.logoff",
"rdp.shadow.control.started", "rdp.shadow.control.started",
"rdp.shadow.control.stopped", "rdp.shadow.control.stopped",
"rdp.shadow.control.permission", "rdp.shadow.control.permission",
+4
View File
@@ -6,6 +6,7 @@ from app.services.event_actor_user import extract_event_actor_user
from app.services.host_sessions import event_session_terminated 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
from app.services.session_duration import extract_session_duration_sec
def event_to_summary(event: Event, db: Session | None = None) -> EventSummary: def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
@@ -44,6 +45,9 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
title=title, title=title,
summary=summary, summary=summary,
actor_user=extract_event_actor_user(event.type, event.details), actor_user=extract_event_actor_user(event.type, event.details),
session_duration_sec=extract_session_duration_sec(
event.details if isinstance(event.details, dict) else None
),
rdg_flap=rdg_flap, rdg_flap=rdg_flap,
rdg_flap_pair_event_id=rdg_flap_pair_event_id, rdg_flap_pair_event_id=rdg_flap_pair_event_id,
rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id, rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
@@ -0,0 +1,116 @@
"""Per-host management credentials (override global Win/Linux admin)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.models.host import Host
from app.services.linux_admin_settings import (
LinuxAdminConfig,
get_effective_linux_admin_for_host,
)
from app.services.win_admin_settings import (
WinAdminConfig,
get_effective_win_admin_for_host,
normalize_win_admin_user,
)
def mask_secret(value: str | None) -> str | None:
if not value:
return None
text = value.strip()
if not text:
return None
if len(text) <= 4:
return "****"
return f"{text[:2]}{text[-2:]}"
@dataclass(frozen=True)
class HostMgmtAccessView:
host_id: int
has_override: bool
user: str | None
password_set: bool
password_hint: str | None
effective_source: str
effective_configured: bool
effective_user: str | None
def _effective_for_host(db: Session, host: Host) -> WinAdminConfig | LinuxAdminConfig:
os_family = (host.os_family or "").strip().lower()
if os_family == "windows" or (host.product or "").strip().lower() in {"rdp-login-monitor", "rdp"}:
return get_effective_win_admin_for_host(db, host)
if os_family == "linux" or (host.product or "").strip().lower() in {"ssh-monitor", "ssh"}:
return get_effective_linux_admin_for_host(db, host)
# Fallback: try host override first via win helper (same columns), then global win, then linux.
win_cfg = get_effective_win_admin_for_host(db, host)
if win_cfg.configured:
return win_cfg
return get_effective_linux_admin_for_host(db, host)
def get_host_mgmt_access_view(db: Session, host: Host) -> HostMgmtAccessView:
host_user = (host.mgmt_user or "").strip() or None
host_password = (host.mgmt_password or "").strip() or None
has_override = bool(host_user or host_password)
effective = _effective_for_host(db, host)
return HostMgmtAccessView(
host_id=host.id,
has_override=has_override,
user=host_user,
password_set=bool(host_password),
password_hint=mask_secret(host_password),
effective_source=effective.source,
effective_configured=effective.configured,
effective_user=effective.user or None,
)
def upsert_host_mgmt_credentials(
db: Session,
host: Host,
*,
user: str | None = None,
password: str | None = None,
clear: bool = False,
) -> HostMgmtAccessView:
"""Update per-host credentials.
- clear=True → wipe host override (use global Settings).
- user="" → clear username.
- password omitted (None) → keep existing password.
- password="" → clear password.
"""
if clear:
host.mgmt_user = None
host.mgmt_password = None
db.commit()
db.refresh(host)
return get_host_mgmt_access_view(db, host)
if user is not None:
cleaned = user.strip()
if not cleaned:
host.mgmt_user = None
else:
# Preserve DOMAIN\\user form for Windows; for Linux leave as-is after strip.
os_family = (host.os_family or "").strip().lower()
if os_family == "windows" or "\\" in cleaned or "@" in cleaned:
host.mgmt_user = normalize_win_admin_user(cleaned)
else:
host.mgmt_user = cleaned
if password is not None:
if not password.strip():
host.mgmt_password = None
else:
host.mgmt_password = password
db.commit()
db.refresh(host)
return get_host_mgmt_access_view(db, host)
+156 -5
View File
@@ -14,9 +14,14 @@ from app.database import SessionLocal
from app.models import Host from app.models import Host
from app.services.agent_update import execute_agent_update_fallback from app.services.agent_update import execute_agent_update_fallback
from app.services.agent_update_types import AgentUpdateFallbackResult from app.services.agent_update_types import AgentUpdateFallbackResult
from app.services.linux_admin_settings import get_effective_linux_admin_config from app.services.linux_admin_settings import get_effective_linux_admin_for_host
from app.services.agent_update_settings import get_effective_agent_update_config from app.services.agent_update_settings import get_effective_agent_update_config
from app.services.ssh_connect import iter_ssh_targets, run_ssh_monitor_update, SshCommandResult from app.services.ssh_connect import (
iter_ssh_targets,
run_ssh_monitor_update,
SshCommandResult,
tail_ssh_monitor_update_log,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -76,6 +81,61 @@ def _result_payload(
} }
def _completion_title(base_title: str, result: SshCommandResult) -> str:
if result.ok:
return f"{base_title} — готово"
return f"{base_title} — ошибка"
def _completion_message(result: SshCommandResult) -> str:
if result.ok:
version = (result.agent_version or "").strip()
if version:
return f"Обновление завершено успешно. Версия агента: {version}"
return "Обновление завершено успешно"
return (result.message or "Обновление не удалось").strip()
def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str:
cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured:
return ""
try:
targets = iter_ssh_targets(host)
except Exception:
return ""
for target in targets:
try:
tail = tail_ssh_monitor_update_log(
target=target,
user=cfg.user,
password=cfg.password,
lines=lines,
)
if tail:
return tail
except Exception:
logger.debug("final update log tail failed host_id=%s target=%s", host.id, target, exc_info=True)
return ""
def _enrich_ssh_update_payload(
payload: dict[str, Any],
*,
base_title: str,
result: SshCommandResult,
previous_output: str,
log_tail: str,
) -> dict[str, Any]:
payload["title"] = _completion_title(base_title, result)
payload["message"] = _completion_message(result)
if log_tail:
payload["output"] = log_tail
elif previous_output.strip():
payload["output"] = previous_output
return payload
def _mark_running(db: Session, host: Host, *, title: str) -> str: def _mark_running(db: Session, host: Host, *, title: str) -> str:
started_at = _utcnow().isoformat() started_at = _utcnow().isoformat()
host.agent_update_state = "running" host.agent_update_state = "running"
@@ -113,12 +173,70 @@ def _apply_ssh_update_result(db: Session, host: Host, result: SshCommandResult)
host.agent_update_last_error = (result.message or "SSH update failed")[:2000] host.agent_update_last_error = (result.message or "SSH update failed")[:2000]
_REMOTE_LOG_POLL_SEC = 2.0
def _poll_ssh_update_log_loop(
host_id: int,
*,
stop: threading.Event,
) -> None:
"""Периодически подтягивает tail update_script.log в hosts.remote_action для UI."""
while not stop.wait(_REMOTE_LOG_POLL_SEC):
session = SessionLocal()
try:
row = session.get(Host, host_id)
if row is None or (row.agent_update_state or "").strip().lower() != "running":
continue
payload = dict(row.remote_action or {})
if (payload.get("status") or "").strip().lower() != "running":
continue
cfg = get_effective_linux_admin_for_host(session, row)
if not cfg.configured:
continue
try:
targets = iter_ssh_targets(row)
except Exception:
continue
tail = ""
for target in targets:
try:
tail = tail_ssh_monitor_update_log(
target=target,
user=cfg.user,
password=cfg.password,
lines=200,
)
except Exception:
logger.debug("update log tail failed host_id=%s target=%s", host_id, target, exc_info=True)
continue
if tail:
break
session.refresh(row)
if (row.agent_update_state or "").strip().lower() != "running":
continue
payload = dict(row.remote_action or {})
if (payload.get("status") or "").strip().lower() != "running":
continue
if tail:
payload["output"] = tail
payload["message"] = "Выполняется обновление… (лог с хоста)"
row.remote_action = payload
session.commit()
except Exception:
logger.debug("remote log poll failed host_id=%s", host_id, exc_info=True)
session.rollback()
finally:
session.close()
def start_host_remote_action( def start_host_remote_action(
db: Session, db: Session,
host: Host, host: Host,
*, *,
title: str, title: str,
runner: ActionRunner, runner: ActionRunner,
poll_remote_log: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
host_id = int(host.id) host_id = int(host.id)
with _lock: with _lock:
@@ -133,13 +251,35 @@ def start_host_remote_action(
def _worker() -> None: def _worker() -> None:
session = SessionLocal() session = SessionLocal()
stop_poll = threading.Event()
poll_thread: threading.Thread | None = None
if poll_remote_log and runner is run_ssh_monitor_update_action:
poll_thread = threading.Thread(
target=_poll_ssh_update_log_loop,
args=(host_id,),
kwargs={"stop": stop_poll},
name=f"sac-remote-log-{host_id}",
daemon=True,
)
poll_thread.start()
try: try:
row = session.get(Host, host_id) row = session.get(Host, host_id)
if row is None: if row is None:
return return
previous_output = (row.remote_action or {}).get("output") or ""
result = runner(session, row) result = runner(session, row)
started = (row.remote_action or {}).get("started_at") or started_at started = (row.remote_action or {}).get("started_at") or started_at
row.remote_action = _result_payload(result, title=title, started_at=started) payload = _result_payload(result, title=title, started_at=started)
if poll_remote_log and isinstance(result, SshCommandResult):
log_tail = _fetch_ssh_update_log_tail(session, row)
payload = _enrich_ssh_update_payload(
payload,
base_title=title,
result=result,
previous_output=previous_output,
log_tail=log_tail,
)
row.remote_action = payload
if isinstance(result, SshCommandResult): if isinstance(result, SshCommandResult):
_apply_ssh_update_result(session, row, result) _apply_ssh_update_result(session, row, result)
session.commit() session.commit()
@@ -167,6 +307,9 @@ def start_host_remote_action(
} }
session.commit() session.commit()
finally: finally:
stop_poll.set()
if poll_thread is not None:
poll_thread.join(timeout=5.0)
session.close() session.close()
with _lock: with _lock:
_running.discard(host_id) _running.discard(host_id)
@@ -179,7 +322,7 @@ def start_host_remote_action(
def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult: def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult:
cfg = get_effective_linux_admin_config(db) cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured: if not cfg.configured:
return SshCommandResult( return SshCommandResult(
ok=False, ok=False,
@@ -276,8 +419,16 @@ def get_remote_action_status(host: Host) -> dict[str, Any]:
payload = dict(host.remote_action or {}) payload = dict(host.remote_action or {})
if not payload: if not payload:
return {"active": False, "host_id": host.id} return {"active": False, "host_id": host.id}
agent_state = (host.agent_update_state or "").strip().lower()
if agent_state == "running":
active = True
status = payload.get("status") or "running"
elif agent_state in ("success", "failed"):
active = False
status = payload.get("status") or agent_state
else:
status = payload.get("status") or host.agent_update_state or "unknown" status = payload.get("status") or host.agent_update_state or "unknown"
active = status == "running" or host.agent_update_state == "running" active = status == "running"
return { return {
"active": active, "active": active,
"host_id": host.id, "host_id": host.id,
+81 -16
View File
@@ -84,6 +84,10 @@ def _event_login_user(event: Event) -> str:
def event_session_terminated(event: Event, db: Session | None = None) -> bool: def event_session_terminated(event: Event, db: Session | None = None) -> bool:
from app.services.rdp_session_logoff import (
event_closed_by_logoff,
resolve_workstation_login_closed_by_logoff,
)
from app.services.rdg_workstation_session import ( from app.services.rdg_workstation_session import (
event_closed_by_rdg, event_closed_by_rdg,
resolve_workstation_login_closed, resolve_workstation_login_closed,
@@ -97,7 +101,11 @@ def event_session_terminated(event: Event, db: Session | None = None) -> bool:
return True return True
if event_closed_by_rdg(event): if event_closed_by_rdg(event):
return True return True
if event_closed_by_logoff(event):
return True
if db is not None and event.type == "rdp.login.success": if db is not None and event.type == "rdp.login.success":
if resolve_workstation_login_closed_by_logoff(db, event):
return True
return resolve_workstation_login_closed(db, event) return resolve_workstation_login_closed(db, event)
return False return False
@@ -376,28 +384,79 @@ def terminate_linux_session(
return last return last
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]: def _normalize_sam_account(user: str) -> str:
rows: list[HostSessionRow] = [] text = (user or "").strip()
norm_filter = (filter_user or "").strip().lower() if "\\" in text:
return text.split("\\")[-1].strip().casefold()
if "@" in text:
return text.split("@")[0].strip().casefold()
return text.casefold()
def norm_user(value: str) -> str:
return value.replace("B26\\", "").replace("b26\\", "").lower() def windows_user_matches_session(login_user: str, session_user: str) -> bool:
login = _normalize_sam_account(login_user)
session = _normalize_sam_account(session_user)
return bool(login and session and login == session)
def filter_windows_sessions_for_user(
sessions: list[HostSessionRow],
login_user: str,
) -> list[HostSessionRow]:
user = (login_user or "").strip()
if not user:
return sessions
return [s for s in sessions if windows_user_matches_session(user, s.user)]
def _qwinsta_sam_account(value: str) -> str:
text = (value or "").strip()
if "\\" in text:
text = text.split("\\")[-1]
if "@" in text:
text = text.split("@")[0]
return text.casefold()
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]:
"""Parse ``qwinsta`` output.
Disconnected sessions often have an empty SESSIONNAME column, so the line
becomes ``USERNAME ID STATE`` (3 tokens). Older parsing required 4 tokens
and skipped those rows — that broke RDG flap auto-logoff for Disc sessions.
"""
rows: list[HostSessionRow] = []
filter_sam = _qwinsta_sam_account(filter_user or "")
skip_users = frozenset({"services"})
for line in stdout.splitlines(): for line in stdout.splitlines():
text = line.strip() text = line.strip()
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"): if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
continue continue
parts = text.split() parts = text.split()
if len(parts) < 4: id_idx: int | None = None
sid = 0
for i, part in enumerate(parts):
token = part.lstrip(">")
if token.isdigit():
id_idx = i
sid = int(token)
break
if id_idx is None or id_idx < 1:
continue continue
session_name = parts[0].lstrip(">") state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else ""
user_name = parts[1] if not state or state.casefold().startswith("listen"):
try:
sid = int(parts[2])
except ValueError:
continue continue
state = " ".join(parts[3:]) before = parts[:id_idx]
if norm_filter and norm_filter not in norm_user(user_name): if len(before) == 1:
session_name = ""
user_name = before[0].lstrip(">")
else:
session_name = before[0].lstrip(">")
user_name = before[1]
if user_name.casefold() in skip_users:
continue
if filter_sam and filter_sam not in _qwinsta_sam_account(user_name):
continue continue
rows.append( rows.append(
HostSessionRow( HostSessionRow(
@@ -408,7 +467,7 @@ def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> li
) )
) )
if rows or not norm_filter: if rows or not filter_sam:
return rows return rows
return parse_qwinsta_sessions(stdout, filter_user=None) return parse_qwinsta_sessions(stdout, filter_user=None)
@@ -475,11 +534,17 @@ def terminate_session_for_event(
sessions, qwinsta = list_windows_sessions(host, win_cfg) sessions, qwinsta = list_windows_sessions(host, win_cfg)
if not qwinsta or not qwinsta.ok: if not qwinsta or not qwinsta.ok:
raise ValueError(qwinsta.message if qwinsta else "qwinsta failed") raise ValueError(qwinsta.message if qwinsta else "qwinsta failed")
matched = [s for s in sessions if user.lower() in s.user.lower()] if user else sessions matched = filter_windows_sessions_for_user(sessions, user) if user else sessions
if len(matched) == 1: if len(matched) == 1:
sid = matched[0].session_id sid = matched[0].session_id
elif not matched: elif not matched:
raise ValueError("No matching Windows session for user") # Пользователь уже вышел из RDP — qwinsta пуст, событие входа в SAC ещё «открыто».
return WinRmCmdResult(
ok=True,
message="На хосте нет активной сессии пользователя (уже вышел из RDP)",
target=qwinsta.target,
stdout=qwinsta.stdout,
)
else: else:
raise ValueError("Multiple sessions; specify session_id") raise ValueError("Multiple sessions; specify session_id")
result = terminate_windows_session(host, win_cfg, sid) result = terminate_windows_session(host, win_cfg, sid)
+11
View File
@@ -9,6 +9,7 @@ from app.services.agent_update import process_agent_update_ingest
from app.services.daily_report_format import normalize_daily_report_details from app.services.daily_report_format import normalize_daily_report_details
from app.services.event_severity_overrides import apply_severity_override from app.services.event_severity_overrides import apply_severity_override
from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest
from app.services.rdp_session_logoff import close_workstation_session_for_rdp_logoff
from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"}) DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
@@ -127,5 +128,15 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
raise raise
process_agent_update_ingest(db, host, payload.get("type", ""), details) process_agent_update_ingest(db, host, payload.get("type", ""), details)
from app.services.rdg_workstation_session import (
enrich_empty_login_from_rdg_success,
enrich_workstation_login_user_from_rdg,
)
if event.host is None:
event.host = host
enrich_workstation_login_user_from_rdg(db, event)
enrich_empty_login_from_rdg_success(db, event)
close_workstation_session_for_rdg_end(db, event) close_workstation_session_for_rdg_end(db, event)
close_workstation_session_for_rdp_logoff(db, event)
return event, True return event, True
+16 -2
View File
@@ -1,4 +1,4 @@
"""Effective Linux SSH admin credentials (DB overrides env).""" """Effective Linux SSH admin credentials (host → DB → env)."""
from __future__ import annotations from __future__ import annotations
@@ -7,6 +7,7 @@ from dataclasses import dataclass
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.models.host import Host
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
@@ -14,7 +15,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
class LinuxAdminConfig: class LinuxAdminConfig:
user: str user: str
password: str password: str
source: str # env | db source: str # host | env | db
@property @property
def configured(self) -> bool: def configured(self) -> bool:
@@ -80,3 +81,16 @@ def upsert_linux_admin_settings(
db.commit() db.commit()
db.refresh(row) db.refresh(row)
return get_effective_linux_admin_config(db) return get_effective_linux_admin_config(db)
def get_effective_linux_admin_for_host(db: Session, host: Host) -> LinuxAdminConfig:
"""Prefer per-host mgmt_* when both user and password are set; else global Settings/env."""
host_user = (host.mgmt_user or "").strip()
host_password = (host.mgmt_password or "").strip()
if host_user and host_password:
return LinuxAdminConfig(user=host_user, password=host_password, source="host")
global_cfg = get_effective_linux_admin_config(db)
if host_user and global_cfg.password.strip():
return LinuxAdminConfig(user=host_user, password=global_cfg.password, source="host")
return global_cfg
+88 -4
View File
@@ -4,7 +4,7 @@ import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Event, Problem from app.models import Event, Host, Problem
from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify
from app.services.notification_cooldown import should_notify_event, should_notify_problem from app.services.notification_cooldown import should_notify_event, should_notify_problem
from app.services.notification_policy import get_effective_notification_policy from app.services.notification_policy import get_effective_notification_policy
@@ -15,6 +15,14 @@ from app.services.notification_severity import severity_meets_minimum
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
LIFECYCLE_EVENT_TYPE = "agent.lifecycle" LIFECYCLE_EVENT_TYPE = "agent.lifecycle"
PRIVILEGE_SUDO_TYPE = "privilege.sudo.command"
SUDO_MAINTENANCE_MARKERS = (
"update_ssh_monitor.sh",
"update_via_sac",
"update_script.log",
"/opt/scripts/update",
"agent-update-in-progress",
)
DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"}) DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"}) AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"})
RDG_CONNECTION_TYPES = frozenset({ RDG_CONNECTION_TYPES = frozenset({
@@ -67,6 +75,8 @@ def notify_event(event: Event, *, db: Session | None = None) -> None:
# Heartbeat — только для UI/статуса хоста, не для Telegram/email/push. # Heartbeat — только для UI/статуса хоста, не для Telegram/email/push.
if event.type == HEARTBEAT_TYPE: if event.type == HEARTBEAT_TYPE:
return return
if _should_suppress_sudo_notify(event, db=db):
return
if _skip_notifications_for_hidden_event(event, db): if _skip_notifications_for_hidden_event(event, db):
return return
policy = get_effective_notification_policy(db) policy = get_effective_notification_policy(db)
@@ -111,8 +121,68 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
_dispatch_lifecycle_channels(event, db=db, policy=policy) _dispatch_lifecycle_channels(event, db=db, policy=policy)
def _host_sac_update_running(event: Event, db: Session | None) -> bool:
"""Пока SAC выполняет agent-update на хосте — не слать шумные TG."""
if db is None or not event.host_id:
return False
host = db.get(Host, event.host_id)
if host is None:
return False
if (host.agent_update_state or "").strip().lower() == "running":
logger.info(
"notify skipped (host update running) type=%s host_id=%s event_id=%s",
event.type,
host.id,
event.event_id,
)
return True
return False
def _sudo_event_is_agent_maintenance(event: Event) -> bool:
details = event.details if isinstance(event.details, dict) else {}
cmd = str(details.get("command") or "").strip()
if not cmd:
cmd = str(event.summary or "").strip()
text = cmd.casefold()
return any(marker in text for marker in SUDO_MAINTENANCE_MARKERS)
def _should_suppress_sudo_notify(event: Event, *, db: Session | None) -> bool:
if event.type != PRIVILEGE_SUDO_TYPE:
return False
if _host_sac_update_running(event, db):
return True
if _sudo_event_is_agent_maintenance(event):
logger.info(
"notify sudo skipped maintenance command event_id=%s host_id=%s",
event.event_id,
event.host_id,
)
return True
return False
def _lifecycle_suppress_notifications(event: Event, *, db: Session | None = None) -> bool:
"""Не слать TG при штатном SAC/cron update (lifecycle с trigger deploy_recycle)."""
if _host_sac_update_running(event, db):
return True
details = event.details if isinstance(event.details, dict) else {}
trigger = str(details.get("trigger") or "").strip().lower()
if trigger in ("deploy_recycle", "sac_update", "agent_update"):
logger.info(
"notify lifecycle skipped trigger=%s event_id=%s",
trigger,
event.event_id,
)
return True
return False
def notify_lifecycle(event: Event, *, db: Session | None = None) -> None: def notify_lifecycle(event: Event, *, db: Session | None = None) -> None:
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent).""" """Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
if _lifecycle_suppress_notifications(event, db=db):
return
if _skip_notifications_for_hidden_event(event, db): if _skip_notifications_for_hidden_event(event, db):
return return
policy = get_effective_notification_policy(db) policy = get_effective_notification_policy(db)
@@ -146,16 +216,30 @@ def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None:
def schedule_notify_daily_report(event_db_id: int) -> None: def schedule_notify_daily_report(event_db_id: int) -> None:
"""Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST).""" """Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST)."""
_schedule_deferred_event_notify(event_db_id, notify_daily_report, label="daily report")
def schedule_notify_lifecycle(event_db_id: int) -> None:
"""Отложенное lifecycle-оповещение (после commit ingest)."""
_schedule_deferred_event_notify(event_db_id, notify_lifecycle, label="lifecycle")
def schedule_notify_auth_login(event_db_id: int) -> None:
"""Отложенное оповещение об успешном RDP/SSH входе (после commit ingest)."""
_schedule_deferred_event_notify(event_db_id, notify_auth_login, label="auth login")
def _schedule_deferred_event_notify(event_db_id: int, handler, *, label: str) -> None:
from app.database import SessionLocal from app.database import SessionLocal
db = SessionLocal() db = SessionLocal()
try: try:
event = db.get(Event, event_db_id) event = db.get(Event, event_db_id)
if event is None: if event is None:
logger.warning("deferred daily report notify: event id=%s not found", event_db_id) logger.warning("deferred %s notify: event id=%s not found", label, event_db_id)
return return
notify_daily_report(event, db=db) handler(event, db=db)
except Exception: except Exception:
logger.exception("deferred daily report notify failed event_db_id=%s", event_db_id) logger.exception("deferred %s notify failed event_db_id=%s", label, event_db_id)
finally: finally:
db.close() db.close()
+10 -5
View File
@@ -12,7 +12,7 @@ from app.models import AgentCommand, Event, Host
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
from app.services.rdg_display import event_supports_rdg_client_qwinsta from app.services.rdg_display import event_supports_rdg_client_qwinsta
from app.services.rdg_session_flap import event_internal_ip from app.services.rdg_session_flap import event_internal_ip
from app.services.win_admin_settings import get_effective_win_admin_config from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.winrm_connect import ( from app.services.winrm_connect import (
WinRmCmdResult, WinRmCmdResult,
run_winrm_logoff, run_winrm_logoff,
@@ -21,12 +21,17 @@ from app.services.winrm_connect import (
) )
def _require_win_admin(db: Session): def _require_win_admin(db: Session, host: Host | None = None):
if host is not None:
cfg = get_effective_win_admin_for_host(db, host)
else:
from app.services.win_admin_settings import get_effective_win_admin_config
cfg = get_effective_win_admin_config(db) cfg = get_effective_win_admin_config(db)
if not cfg.configured: if not cfg.configured:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)", detail="Windows admin is not configured (host override or Settings → Windows)",
) )
return cfg return cfg
@@ -93,8 +98,8 @@ def _persist_command(
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand: def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
_require_rdg_client_qwinsta(db, event) _require_rdg_client_qwinsta(db, event)
cfg = _require_win_admin(db)
client_host = _resolve_client(db, event) client_host = _resolve_client(db, event)
cfg = _require_win_admin(db, client_host)
details = event.details if isinstance(event.details, dict) else {} details = event.details if isinstance(event.details, dict) else {}
user = details.get("user") user = details.get("user")
@@ -136,8 +141,8 @@ def execute_logoff_via_winrm(
requested_by: str, requested_by: str,
) -> AgentCommand: ) -> AgentCommand:
_require_rdg_client_qwinsta(db, event) _require_rdg_client_qwinsta(db, event)
cfg = _require_win_admin(db)
client_host = _resolve_client(db, event) client_host = _resolve_client(db, event)
cfg = _require_win_admin(db, client_host)
details = event.details if isinstance(event.details, dict) else {} details = event.details if isinstance(event.details, dict) else {}
user = details.get("user") user = details.get("user")
+134 -3
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
@@ -15,6 +17,7 @@ from app.services.host_sessions import (
from app.services.rdg_client_host import find_windows_host_by_ipv4 from app.services.rdg_client_host import find_windows_host_by_ipv4
from app.services.rdg_session_flap import ( from app.services.rdg_session_flap import (
RDG_END_TYPES, RDG_END_TYPES,
RDG_SUCCESS_TYPE,
_event_user, _event_user,
event_internal_ip, event_internal_ip,
find_rdg_success_before_end, find_rdg_success_before_end,
@@ -22,10 +25,22 @@ from app.services.rdg_session_flap import (
SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at" SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at"
SESSION_CLOSED_BY_RDG_EVENT_ID_KEY = "session_closed_by_rdg_event_id" SESSION_CLOSED_BY_RDG_EVENT_ID_KEY = "session_closed_by_rdg_event_id"
USER_ENRICHED_FROM_RDG_EVENT_ID_KEY = "user_enriched_from_rdg_event_id"
# RCM 1149 via RD Gateway often has empty Param1/Param2; RDG 302 has the account.
RDG_LOGIN_USER_ENRICH_WINDOW = timedelta(minutes=5)
WORKSTATION_LOGIN_TYPE = "rdp.login.success" WORKSTATION_LOGIN_TYPE = "rdp.login.success"
def _as_utc(dt):
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def normalize_sam_account(user: str) -> str: def normalize_sam_account(user: str) -> str:
text = (user or "").strip() text = (user or "").strip()
if "\\" in text: if "\\" in text:
@@ -41,6 +56,115 @@ def users_match_rdg(login_user: str, rdg_user: str) -> bool:
return bool(left and right and left == right) return bool(left and right and left == right)
def _login_user_missing(event: Event) -> bool:
details = _details_dict(event)
for key in ("user", "username"):
val = details.get(key)
if val is not None and str(val).strip() not in ("", "-"):
return False
return True
def _apply_rdg_user_to_login(login_event: Event, *, rdg_event: Event, rdg_user: str) -> None:
details = dict(_details_dict(login_event))
details["user"] = rdg_user
details[USER_ENRICHED_FROM_RDG_EVENT_ID_KEY] = rdg_event.id
login_event.details = details
flag_modified(login_event, "details")
summary = (login_event.summary or "").strip()
if summary.startswith("RCM 1149") and rdg_user not in summary:
rest = summary[len("RCM 1149") :].strip()
login_event.summary = f"RCM 1149 {rdg_user} {rest}".strip()
def find_rdg_success_for_workstation_login(db: Session, login_event: Event) -> Event | None:
"""Nearest RDG 302 for this workstation IP within the enrich window."""
if login_event.type != WORKSTATION_LOGIN_TYPE:
return None
host = login_event.host
if host is None or not (host.ipv4 or "").strip():
return None
workstation_ip = host.ipv4.strip()
login_at = _as_utc(login_event.occurred_at)
window_start = login_at - RDG_LOGIN_USER_ENRICH_WINDOW
window_end = login_at + RDG_LOGIN_USER_ENRICH_WINDOW
candidates = db.scalars(
select(Event)
.where(
Event.type == RDG_SUCCESS_TYPE,
Event.occurred_at >= window_start,
Event.occurred_at <= window_end,
Event.id != login_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
best: Event | None = None
best_delta: timedelta | None = None
for rdg in candidates:
if event_internal_ip(rdg) != workstation_ip:
continue
if not _event_user(rdg):
continue
delta = abs(_as_utc(rdg.occurred_at) - login_at)
if best is None or best_delta is None or delta < best_delta:
best = rdg
best_delta = delta
return best
def enrich_workstation_login_user_from_rdg(db: Session, login_event: Event) -> Event | None:
"""Fill details.user when RCM 1149 EventLog left Param1/Param2 empty (seen on some Win10 Pro)."""
if login_event.type != WORKSTATION_LOGIN_TYPE:
return None
if not _login_user_missing(login_event):
return None
rdg = find_rdg_success_for_workstation_login(db, login_event)
if rdg is None:
return None
rdg_user = _event_user(rdg)
if not rdg_user:
return None
_apply_rdg_user_to_login(login_event, rdg_event=rdg, rdg_user=rdg_user)
return login_event
def enrich_empty_login_from_rdg_success(db: Session, rdg_success_event: Event) -> Event | None:
"""Backfill empty workstation 1149 when RDG 302 is ingested after it."""
if rdg_success_event.type != RDG_SUCCESS_TYPE:
return None
internal_ip = event_internal_ip(rdg_success_event)
rdg_user = _event_user(rdg_success_event)
if not internal_ip or not rdg_user:
return None
client_host = find_windows_host_by_ipv4(db, internal_ip)
if client_host is None:
return None
rdg_at = _as_utc(rdg_success_event.occurred_at)
window_start = rdg_at - RDG_LOGIN_USER_ENRICH_WINDOW
window_end = rdg_at + RDG_LOGIN_USER_ENRICH_WINDOW
candidates = db.scalars(
select(Event)
.where(
Event.host_id == client_host.id,
Event.type == WORKSTATION_LOGIN_TYPE,
Event.occurred_at >= window_start,
Event.occurred_at <= window_end,
Event.id != rdg_success_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
for login in candidates:
if not _login_user_missing(login):
continue
_apply_rdg_user_to_login(login, rdg_event=rdg_success_event, rdg_user=rdg_user)
return login
return None
def event_closed_by_rdg(event: Event) -> bool: def event_closed_by_rdg(event: Event) -> bool:
details = _details_dict(event) details = _details_dict(event)
at = details.get(SESSION_CLOSED_BY_RDG_AT_KEY) at = details.get(SESSION_CLOSED_BY_RDG_AT_KEY)
@@ -56,13 +180,17 @@ def mark_login_closed_by_rdg(login_event: Event, *, rdg_end_event: Event) -> Non
def _login_already_closed(login_event: Event) -> bool: def _login_already_closed(login_event: Event) -> bool:
from app.services.rdp_session_logoff import event_closed_by_logoff
details = _details_dict(login_event) details = _details_dict(login_event)
if details.get("session_terminated") is True: if details.get("session_terminated") is True:
return True return True
at = details.get(SESSION_TERMINATED_AT_KEY) at = details.get(SESSION_TERMINATED_AT_KEY)
if at is not None and str(at).strip() != "": if at is not None and str(at).strip() != "":
return True return True
return event_closed_by_rdg(login_event) if event_closed_by_rdg(login_event):
return True
return event_closed_by_logoff(login_event)
def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None: def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
@@ -94,8 +222,11 @@ def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Eve
for login in candidates: for login in candidates:
if _login_already_closed(login): if _login_already_closed(login):
continue continue
login_user = _event_login_user(login) login_user = (_event_login_user(login) or "").strip()
if not users_match_rdg(login_user, rdg_user): if login_user in ("", "-"):
login_user = ""
# RCM 1149 may lack user (empty Param1); still close by workstation IP + open session.
if login_user and not users_match_rdg(login_user, rdg_user):
continue continue
return login return login
return None return None
@@ -35,7 +35,7 @@ from app.services.rdg_workstation_session import (
users_match_rdg, users_match_rdg,
) )
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings from app.services.rdp_flap_settings import get_effective_rdp_flap_settings
from app.services.win_admin_settings import get_effective_win_admin_config from app.services.win_admin_settings import get_effective_win_admin_for_host
logger = logging.getLogger("sac.rdp_flap_auto_disconnect") logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
@@ -143,7 +143,7 @@ def _disconnect_on_workstation(
user: str, user: str,
login_event: Event | None, login_event: Event | None,
) -> AutoDisconnectResult: ) -> AutoDisconnectResult:
win_cfg = get_effective_win_admin_config(db) win_cfg = get_effective_win_admin_for_host(db, workstation)
sessions, qwinsta = list_windows_sessions(workstation, win_cfg) sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
if qwinsta is None or not qwinsta.ok: if qwinsta is None or not qwinsta.ok:
message = qwinsta.message if qwinsta else "qwinsta failed" message = qwinsta.message if qwinsta else "qwinsta failed"
@@ -155,13 +155,23 @@ def _disconnect_on_workstation(
login_event_id=login_event.id if login_event else None, login_event_id=login_event.id if login_event else None,
) )
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout, filter_user=user), user) parsed = parse_qwinsta_sessions(qwinsta.stdout, filter_user=user)
matched = _sessions_for_user(parsed, user)
if not matched and qwinsta.stdout.strip(): if not matched and qwinsta.stdout.strip():
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout), user) parsed = parse_qwinsta_sessions(qwinsta.stdout)
matched = _sessions_for_user(parsed, user)
if not matched: if not matched:
snippet = " ".join(qwinsta.stdout.split())[:240]
parsed_note = f", parsed={len(parsed)} session(s)" if parsed else ""
message = (
f"No matching Windows session for user {user} on {workstation.hostname}"
f"{parsed_note}"
)
if snippet:
message = f"{message}; qwinsta: {snippet}"
return AutoDisconnectResult( return AutoDisconnectResult(
ok=False, ok=False,
message="No matching Windows session for user", message=message,
trigger_event_id=trigger_event.id, trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id, workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None, login_event_id=login_event.id if login_event else None,
@@ -224,11 +234,13 @@ def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult
try: try:
workstation = resolve_client_workstation(db, rdg_success) workstation = resolve_client_workstation(db, rdg_success)
except ClientWorkstationNotFoundError as exc: except ClientWorkstationNotFoundError as exc:
return AutoDisconnectResult( result = AutoDisconnectResult(
ok=False, ok=False,
message=str(exc), message=str(exc),
trigger_event_id=event.id, trigger_event_id=event.id,
) )
_mark_auto_disconnect(event, result=result)
return result
rdg_end = event if event.type in RDG_END_TYPES else db.get(Event, _stored_flap_pair_id(event) or -1) rdg_end = event if event.type in RDG_END_TYPES else db.get(Event, _stored_flap_pair_id(event) or -1)
login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None
@@ -279,14 +291,6 @@ def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDi
if not get_effective_rdp_flap_settings(db).auto_disconnect: if not get_effective_rdp_flap_settings(db).auto_disconnect:
return None return None
win_cfg = get_effective_win_admin_config(db)
if not win_cfg.configured:
logger.warning(
"auto rdp flap disconnect skipped: win admin not configured (event_id=%s)",
event.event_id,
)
return None
result = _auto_disconnect_rdg_flap(db, event) result = _auto_disconnect_rdg_flap(db, event)
if result is not None: if result is not None:
if result.ok: if result.ok:
+148
View File
@@ -0,0 +1,148 @@
"""Correlate direct RDP logoff (Security 4634/4647) with workstation rdp.login.success."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import Event
from app.services.host_sessions import (
SESSION_TERMINATED_AT_KEY,
_details_dict,
_event_login_user,
)
from app.services.rdg_workstation_session import (
WORKSTATION_LOGIN_TYPE,
event_closed_by_rdg,
users_match_rdg,
)
SESSION_CLOSED_BY_LOGOFF_AT_KEY = "session_closed_by_logoff_at"
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY = "session_closed_by_logoff_event_id"
LOGOFF_EVENT_TYPE = "rdp.session.logoff"
LOGOFF_EVENT_TYPES = frozenset({LOGOFF_EVENT_TYPE})
def event_closed_by_logoff(event: Event) -> bool:
details = _details_dict(event)
at = details.get(SESSION_CLOSED_BY_LOGOFF_AT_KEY)
return at is not None and str(at).strip() != ""
def mark_login_closed_by_logoff(login_event: Event, *, logoff_event: Event) -> None:
details = dict(_details_dict(login_event))
details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] = logoff_event.occurred_at.isoformat()
details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] = logoff_event.id
login_event.details = details
flag_modified(login_event, "details")
def _login_already_closed(login_event: Event) -> bool:
details = _details_dict(login_event)
if details.get("session_terminated") is True:
return True
at = details.get(SESSION_TERMINATED_AT_KEY)
if at is not None and str(at).strip() != "":
return True
if event_closed_by_rdg(login_event):
return True
return event_closed_by_logoff(login_event)
def find_workstation_login_for_logoff(db: Session, logoff_event: Event) -> Event | None:
if logoff_event.type not in LOGOFF_EVENT_TYPES:
return None
logoff_user = _event_login_user(logoff_event)
if not logoff_user:
return None
logoff_at = logoff_event.occurred_at
host_id = logoff_event.host_id
if host_id is None:
return None
candidates = db.scalars(
select(Event)
.where(
Event.host_id == host_id,
Event.type == WORKSTATION_LOGIN_TYPE,
Event.occurred_at <= logoff_at,
Event.id != logoff_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
logoff_details = _details_dict(logoff_event)
logoff_ip = str(logoff_details.get("ip_address") or "").strip()
for login in candidates:
if _login_already_closed(login):
continue
login_user = _event_login_user(login)
if not users_match_rdg(login_user, logoff_user):
continue
if logoff_ip and logoff_ip not in ("", "-"):
login_ip = str(_details_dict(login).get("ip_address") or "").strip()
if login_ip and login_ip not in ("", "-") and login_ip != logoff_ip:
continue
return login
return None
def find_logoff_after_workstation_login(db: Session, login_event: Event) -> Event | None:
"""Runtime lookup for historical logins without persisted close flag."""
if login_event.type != WORKSTATION_LOGIN_TYPE:
return None
if _login_already_closed(login_event):
return None
host_id = login_event.host_id
if host_id is None:
return None
login_user = _event_login_user(login_event)
if not login_user:
return None
login_at = login_event.occurred_at
login_ip = str(_details_dict(login_event).get("ip_address") or "").strip()
candidates = db.scalars(
select(Event)
.where(
Event.host_id == host_id,
Event.type == LOGOFF_EVENT_TYPE,
Event.occurred_at >= login_at,
Event.id != login_event.id,
)
.order_by(Event.occurred_at.asc())
).all()
for logoff in candidates:
if not users_match_rdg(_event_login_user(logoff), login_user):
continue
logoff_ip = str(_details_dict(logoff).get("ip_address") or "").strip()
if login_ip and login_ip not in ("", "-") and logoff_ip and logoff_ip not in ("", "-"):
if login_ip != logoff_ip:
continue
return logoff
return None
def resolve_workstation_login_closed_by_logoff(db: Session, login_event: Event) -> bool:
if event_closed_by_logoff(login_event):
return True
return find_logoff_after_workstation_login(db, login_event) is not None
def close_workstation_session_for_rdp_logoff(db: Session, logoff_event: Event) -> Event | None:
"""On direct RDP logoff, mark matching open workstation login as session-closed."""
if logoff_event.type not in LOGOFF_EVENT_TYPES:
return None
login = find_workstation_login_for_logoff(db, logoff_event)
if login is None:
return None
mark_login_closed_by_logoff(login, logoff_event=logoff_event)
return login
+37
View File
@@ -0,0 +1,37 @@
"""Format and extract RDP/RDG session_duration_sec for UI."""
from __future__ import annotations
from typing import Any
def extract_session_duration_sec(details: dict[str, Any] | None) -> int | None:
if not isinstance(details, dict):
return None
raw = details.get("session_duration_sec")
if raw is None or raw == "":
return None
try:
value = int(raw)
except (TypeError, ValueError):
return None
if value < 0:
return None
return value
def format_session_duration(seconds: int) -> str:
"""
Human-readable session length for lists:
- under 24h → HH:MM:SS (05:05:24)
- 24h+ → Nд HH:MM:SS (1д 00:01:25)
"""
if seconds < 0:
return ""
days, rem = divmod(int(seconds), 86_400)
hours, rem = divmod(rem, 3600)
minutes, secs = divmod(rem, 60)
clock = f"{hours:02d}:{minutes:02d}:{secs:02d}"
if days:
return f"{days}д {clock}"
return clock
+43 -6
View File
@@ -1,4 +1,4 @@
"""SSH connectivity and remote commands for Linux hosts.""" """SSH connectivity and remote commands for Linux hosts."""
from __future__ import annotations from __future__ import annotations
@@ -9,10 +9,12 @@ from dataclasses import dataclass
from app.config import get_settings from app.config import get_settings
from app.models import Host from app.models import Host
SSH_MONITOR_UPDATE_STATE_FILE = "/var/lib/ssh-monitor/agent-update-in-progress"
SSH_MONITOR_UPDATE_LOG_PATH = "/var/log/update_script.log"
SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh" SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
SSH_MONITOR_UPDATE_DIR = "/opt/scripts/update" SSH_MONITOR_UPDATE_DIR = "/opt/scripts/update"
SSH_MONITOR_REPO_NAME = "ssh-monitor" SSH_MONITOR_REPO_NAME = "ssh-monitor"
SSH_MONITOR_UPDATE_REPO_URL = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git" SSH_MONITOR_UPDATE_REPO_URL = "https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor" SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor"
SSH_MONITOR_VERSION_CMD = ( SSH_MONITOR_VERSION_CMD = (
f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null " f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null "
@@ -433,7 +435,7 @@ def _ssh_monitor_update_invoke_command(
safe_repo = _shell_single_quote(repo_url.strip()) safe_repo = _shell_single_quote(repo_url.strip())
safe_branch = _shell_single_quote((git_branch or "main").strip() or "main") safe_branch = _shell_single_quote((git_branch or "main").strip() or "main")
preflight = _ssh_monitor_preflight_git_remote(repo_url) preflight = _ssh_monitor_preflight_git_remote(repo_url)
return f"{preflight}; REPO_URL={safe_repo} GIT_BRANCH={safe_branch} {SSH_MONITOR_UPDATE_SCRIPT}" return f"{preflight}; UPDATE_VIA_SAC=1 REPO_URL={safe_repo} GIT_BRANCH={safe_branch} {SSH_MONITOR_UPDATE_SCRIPT}"
def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -> str: def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -> str:
@@ -449,7 +451,16 @@ def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -
f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && ' f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && '
f'([ -d "$SCRIPT_NAME" ] || git clone -b "$GIT_BRANCH" "$REPO_URL" "$SCRIPT_NAME") && ' f'([ -d "$SCRIPT_NAME" ] || git clone -b "$GIT_BRANCH" "$REPO_URL" "$SCRIPT_NAME") && '
f'cp "$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" "$INSTALL" && ' f'cp "$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" "$INSTALL" && '
f'chmod 750 "$INSTALL" && REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL"' f'chmod 750 "$INSTALL" && UPDATE_VIA_SAC=1 REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL" --deploy'
)
def _ssh_monitor_mark_update_begin_prefix() -> str:
"""State-file до updater: lifecycle/sudo на агенте глушатся раньше bootstrap."""
return (
f"mkdir -p /var/lib/ssh-monitor && "
f"date +%s > {SSH_MONITOR_UPDATE_STATE_FILE} && "
f"chmod 600 {SSH_MONITOR_UPDATE_STATE_FILE} 2>/dev/null; "
) )
@@ -497,7 +508,7 @@ def run_ssh_monitor_update(
target=target, target=target,
user=user, user=user,
password=password, password=password,
remote_cmd=bootstrap_cmd, remote_cmd=_ssh_monitor_mark_update_begin_prefix() + bootstrap_cmd,
command_timeout_sec=900, command_timeout_sec=900,
need_root=True, need_root=True,
login_shell=False, login_shell=False,
@@ -508,7 +519,7 @@ def run_ssh_monitor_update(
target=target, target=target,
user=user, user=user,
password=password, password=password,
remote_cmd=update_cmd, remote_cmd=_ssh_monitor_mark_update_begin_prefix() + update_cmd,
command_timeout_sec=900, command_timeout_sec=900,
need_root=True, need_root=True,
login_shell=False, login_shell=False,
@@ -549,3 +560,29 @@ def run_ssh_monitor_update(
exit_code=updated.exit_code, exit_code=updated.exit_code,
) )
return updated return updated
def tail_ssh_monitor_update_log(
*,
target: str,
user: str,
password: str,
lines: int = 120,
) -> str:
"""Хвост /var/log/update_script.log на удалённом хосте (для live-лога в SAC UI)."""
safe_lines = max(20, min(int(lines), 400))
remote_cmd = (
f"test -r {SSH_MONITOR_UPDATE_LOG_PATH} && "
f"tail -n {safe_lines} {SSH_MONITOR_UPDATE_LOG_PATH} 2>/dev/null || true"
)
result = run_ssh_command(
target=target,
user=user,
password=password,
remote_cmd=remote_cmd,
command_timeout_sec=25,
need_root=True,
login_shell=False,
)
text = (result.stdout or "").strip()
return _truncate_output(text)
+5 -3
View File
@@ -564,9 +564,11 @@ def _format_rdg_html(event: Event) -> str:
err = _detail(details, "gateway_error_code", "error_code", default="") err = _detail(details, "gateway_error_code", "error_code", default="")
if err != "-": if err != "-":
msg += _line("⚠️", "Код ошибки", html_escape(err)) msg += _line("⚠️", "Код ошибки", html_escape(err))
dur = _detail(details, "session_duration_sec", default="") from app.services.session_duration import extract_session_duration_sec, format_session_duration
if dur not in ("-", "0", ""):
msg += _line("⏱️", "Длительность", f"{html_escape(dur)} с") dur_sec = extract_session_duration_sec(details if isinstance(details, dict) else None)
if dur_sec is not None and dur_sec > 0:
msg += _line("⏱️", "Длительность", html_escape(format_session_duration(dur_sec)))
msg += _line("🕐", "Время", format_time(event.occurred_at)) msg += _line("🕐", "Время", format_time(event.occurred_at))
win_id = _detail(details, "event_id_windows", default="") win_id = _detail(details, "event_id_windows", default="")
if win_id != "-": if win_id != "-":
+17 -2
View File
@@ -1,4 +1,4 @@
"""Effective Windows domain admin credentials (DB overrides env).""" """Effective Windows domain admin credentials (host → DB → env)."""
from __future__ import annotations from __future__ import annotations
@@ -7,6 +7,7 @@ from dataclasses import dataclass
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.models.host import Host
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
@@ -14,7 +15,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
class WinAdminConfig: class WinAdminConfig:
user: str user: str
password: str password: str
source: str # env | db source: str # host | env | db
@property @property
def configured(self) -> bool: def configured(self) -> bool:
@@ -92,3 +93,17 @@ def upsert_win_admin_settings(
db.commit() db.commit()
db.refresh(row) db.refresh(row)
return get_effective_win_admin_config(db) return get_effective_win_admin_config(db)
def get_effective_win_admin_for_host(db: Session, host: Host) -> WinAdminConfig:
"""Prefer per-host mgmt_* when both user and password are set; else global Settings/env."""
host_user = normalize_win_admin_user((host.mgmt_user or "").strip())
host_password = (host.mgmt_password or "").strip()
if host_user and host_password:
return WinAdminConfig(user=host_user, password=host_password, source="host")
global_cfg = get_effective_win_admin_config(db)
# Allow host to override only the username, still using global password (rare).
if host_user and global_cfg.password.strip():
return WinAdminConfig(user=host_user, password=global_cfg.password, source="host")
return global_cfg
+2 -2
View File
@@ -1,4 +1,4 @@
"""WinRM connectivity test for Windows hosts using domain admin credentials.""" """WinRM connectivity test for Windows hosts using domain admin credentials."""
from __future__ import annotations from __future__ import annotations
@@ -487,7 +487,7 @@ def run_winrm_rdp_monitor_update(
) )
effective_repo = ( effective_repo = (
repo_url or "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git" repo_url or "https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
).strip() ).strip()
effective_branch = (git_branch or "main").strip() or "main" effective_branch = (git_branch or "main").strip() or "main"
if not effective_repo: if not effective_repo:
+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.5.0" APP_VERSION = "0.5.17"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+1 -1
View File
@@ -14,7 +14,7 @@ from app.services.agent_version import reference_agent_versions_by_product
def test_normalize_git_repo_url(): def test_normalize_git_repo_url():
assert normalize_git_repo_url("git.kalinamall.ru/PapaTramp/ssh-monitor").endswith( assert normalize_git_repo_url("git.papatramp.ru/PapaTramp/ssh-monitor").endswith(
"ssh-monitor.git" "ssh-monitor.git"
) )
assert normalize_git_repo_url("https://example.com/repo.git") == "https://example.com/repo.git" assert normalize_git_repo_url("https://example.com/repo.git") == "https://example.com/repo.git"
+24
View File
@@ -247,6 +247,30 @@ def test_clear_stale_running_remote_actions(db_session):
assert host.remote_action["ok"] is False assert host.remote_action["ok"] is False
def test_get_remote_action_status_ignores_stale_running_payload(db_session):
from app.services.host_remote_actions import get_remote_action_status
host = Host(
hostname="done-linux",
os_family="linux",
product="ssh-monitor",
agent_update_state="success",
remote_action={
"status": "running",
"message": "Выполняется обновление… (лог с хоста)",
"output": "=== Script update completed successfully ===",
"ok": True,
"finished_at": "2026-07-08T02:21:11+00:00",
},
)
db_session.add(host)
db_session.commit()
status = get_remote_action_status(host)
assert status["active"] is False
assert status["agent_update_state"] == "success"
def test_cancel_host_remote_job_api(jwt_headers, client, db_session): def test_cancel_host_remote_job_api(jwt_headers, client, db_session):
host = Host( host = Host(
hostname="cancel-me", hostname="cancel-me",
@@ -0,0 +1,64 @@
"""Per-host management credential override."""
from app.models.host import Host
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.host_mgmt_credentials import get_host_mgmt_access_view, upsert_host_mgmt_credentials
def test_win_admin_for_host_prefers_override(db_session):
host = Host(
hostname="HOME-PC",
os_family="windows",
product="rdp-login-monitor",
mgmt_user=".\\Admin",
mgmt_password="local-secret",
)
db_session.add(host)
db_session.commit()
db_session.refresh(host)
cfg = get_effective_win_admin_for_host(db_session, host)
assert cfg.configured is True
assert cfg.source == "host"
assert cfg.user == ".\\Admin"
assert cfg.password == "local-secret"
def test_linux_admin_for_host_prefers_override(db_session):
host = Host(
hostname="homeserver",
os_family="linux",
product="ssh-monitor",
mgmt_user="deploy",
mgmt_password="ssh-pass",
)
db_session.add(host)
db_session.commit()
db_session.refresh(host)
cfg = get_effective_linux_admin_for_host(db_session, host)
assert cfg.source == "host"
assert cfg.user == "deploy"
def test_upsert_and_clear_host_access(db_session):
host = Host(hostname="box", os_family="windows", product="rdp-login-monitor")
db_session.add(host)
db_session.commit()
db_session.refresh(host)
view = upsert_host_mgmt_credentials(
db_session,
host,
user="HOME\\user",
password="secret123",
)
assert view.has_override is True
assert view.password_set is True
assert view.user == "HOME\\user"
cleared = upsert_host_mgmt_credentials(db_session, host, clear=True)
assert cleared.has_override is False
assert cleared.password_set is False
assert get_host_mgmt_access_view(db_session, host).user is None
+69
View File
@@ -8,6 +8,7 @@ from app.services.host_sessions import (
event_session_terminated, event_session_terminated,
event_supports_session_terminate, event_supports_session_terminate,
filter_logind_session_rows, filter_logind_session_rows,
filter_windows_sessions_for_user,
mark_event_session_terminated, mark_event_session_terminated,
parse_loginctl_sessions, parse_loginctl_sessions,
parse_loginctl_sessions_json, parse_loginctl_sessions_json,
@@ -73,6 +74,32 @@ def test_parse_qwinsta_sessions_filters_user():
assert filtered[0].session_id == "2" assert filtered[0].session_id == "2"
def test_parse_qwinsta_sessions_disconnected_without_sessionname():
"""Disc rows often omit SESSIONNAME; auto flap logoff relies on these."""
stdout = """SESSIONNAME USERNAME ID STATE
rdp-tcp#0 B26\\s.shelkovaya 2 Active
B26\\s.shelkovaya 5 Disc
rdp-tcp 65536 Listen
services 0 Disc
"""
rows = parse_qwinsta_sessions(stdout)
assert {(r.session_id, r.state.split()[0], r.session_name) for r in rows} == {
("2", "Active", "rdp-tcp#0"),
("5", "Disc", ""),
}
filtered = parse_qwinsta_sessions(stdout, filter_user=r"B26\s.shelkovaya")
assert [r.session_id for r in filtered] == ["2", "5"]
def test_parse_qwinsta_sessions_filters_domain_user():
stdout = """SESSIONNAME USERNAME ID STATE
rdp-tcp#1 B26\\bob 3 Active
"""
rows = parse_qwinsta_sessions(stdout, filter_user=r"B26\bob")
assert len(rows) == 1
assert rows[0].session_id == "3"
def test_event_supports_session_terminate_types(): def test_event_supports_session_terminate_types():
class HostStub: class HostStub:
os_family = "linux" os_family = "linux"
@@ -94,6 +121,48 @@ def test_event_login_user_without_orm_actor_user_attr():
assert _event_login_user(event) == "papatramp" assert _event_login_user(event) == "papatramp"
def test_filter_windows_sessions_for_user_matches_domain():
rows = [
HostSessionRow(session_id="2", user="B26\\papatramp", state="Active"),
HostSessionRow(session_id="3", user="B26\\alice", state="Active"),
]
matched = filter_windows_sessions_for_user(rows, "papatramp")
assert len(matched) == 1
assert matched[0].session_id == "2"
def test_terminate_session_for_event_windows_already_logged_off(monkeypatch):
host = SimpleNamespace(
os_family="windows",
product="rdp-login-monitor",
hostname="BIV-PC",
ipv4="192.168.165.39",
display_name=None,
inventory={},
)
event = SimpleNamespace(
type="rdp.login.success",
details={"user": "papatramp"},
host=host,
)
def fake_list(_host, _cfg):
return [], WinRmCmdResult(ok=True, message="ok", target="BIV-PC", stdout="SESSIONNAME USERNAME ID STATE")
monkeypatch.setattr(
"app.services.host_sessions.list_windows_sessions",
fake_list,
)
result = terminate_session_for_event(
event,
linux_cfg=LinuxAdminConfig(user="", password="", source="test"),
win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"),
)
assert result.ok is True
assert "уже вышел" in result.message
def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch): def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch):
host = SimpleNamespace( host = SimpleNamespace(
os_family="windows", os_family="windows",
+45
View File
@@ -133,3 +133,48 @@ def test_ingest_daily_report_calls_notify_daily_report(client, auth_headers):
mock_daily.assert_called_once() mock_daily.assert_called_once()
mock_event.assert_not_called() mock_event.assert_not_called()
def test_ingest_lifecycle_defers_notify(client, auth_headers):
from unittest.mock import patch
from app.api.v1 import events as events_api
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"type": "agent.lifecycle",
"title": "started",
"summary": "agent started",
"details": {"lifecycle": "started"},
}
with patch.object(events_api, "schedule_notify_lifecycle") as mock_defer:
with patch.object(events_api, "notify_lifecycle") as mock_sync:
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
mock_defer.assert_called_once()
mock_sync.assert_not_called()
def test_ingest_auth_login_defers_notify(client, auth_headers):
from unittest.mock import patch
from app.api.v1 import events as events_api
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"category": "auth",
"type": "ssh.login.success",
"severity": "info",
"title": "SSH login",
"summary": "user@host",
}
with patch.object(events_api, "schedule_notify_auth_login") as mock_defer:
with patch.object(events_api, "notify_auth_login") as mock_sync:
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
mock_defer.assert_called_once()
mock_sync.assert_not_called()
@@ -174,3 +174,51 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch)
assert job["target"] == "ubabuba" assert job["target"] == "ubabuba"
if "product_version" in job: if "product_version" in job:
assert job["product_version"] is None or isinstance(job["product_version"], str) assert job["product_version"] is None or isinstance(job["product_version"], str)
def test_host_agent_update_job_shows_log_tail_and_completion_title(
jwt_headers, client, db_session, monkeypatch
):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
from app.config import get_settings
from app.models import Host
from app.services.ssh_connect import SshCommandResult
from tests.test_agent_update import wait_remote_job
get_settings.cache_clear()
host = Host(hostname="router", os_family="linux", product="ssh-monitor", ipv4="10.0.0.1")
db_session.add(host)
db_session.commit()
db_session.refresh(host)
log_tail = (
"2026-07-08 14:18:15 INFO: === Script update completed successfully ===\n"
"2026-07-08 14:18:15 INFO: завершено успешно (код 0). Итог см. выше\n"
)
with (
patch("app.services.host_remote_actions.run_ssh_monitor_update") as mock_update,
patch("app.services.host_remote_actions.tail_ssh_monitor_update_log") as mock_tail,
):
mock_update.return_value = SshCommandResult(
ok=True,
message="SSH OK (router), exit 0",
target="router",
stdout="",
exit_code=0,
agent_version="2.3.2-SAC",
)
mock_tail.return_value = log_tail
response = client.post(
f"/api/v1/hosts/{host.id}/actions/agent-update",
headers=jwt_headers,
)
assert response.status_code == 202
job = wait_remote_job(client, host.id, jwt_headers)
assert job["ok"] is True
assert "готово" in (job.get("title") or "")
assert "2.3.2-SAC" in (job.get("message") or "")
assert "Script update completed successfully" in (job.get("output") or "")
mock_tail.assert_called()
@@ -266,3 +266,113 @@ def test_ip_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts):
assert close_workstation_session_for_rdg_end(db_session, end) is None assert close_workstation_session_for_rdg_end(db_session, end) is None
assert event_session_terminated(login, db=db_session) is False assert event_session_terminated(login, db=db_session) is False
def test_empty_rcm1149_user_enriched_from_prior_rdg302(db_session, rdg_settings, rdg_hosts):
"""COMM-PC class: EventLog 1149 has empty Param1; RDG 302 already has the account."""
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\s.shelkovaya"
internal_ip = ws.ipv4
_ingest(
db_session,
t0,
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdg.connection.success",
category="auth",
severity="warning",
title="RDG 302",
summary="302",
details={"user": user, "internal_ip": internal_ip},
)
login = _ingest(
db_session,
t0 + timedelta(seconds=3),
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdp.login.success",
category="auth",
severity="warning",
title="RDP connection (RCM 1149)",
summary="RCM 1149 - 192.168.160.40",
details={"user": "-", "ip_address": "192.168.160.40", "event_id_windows": 1149},
)
db_session.refresh(login)
assert login.details["user"] == user
assert login.details.get("user_enriched_from_rdg_event_id")
summary = event_to_summary(login, db_session)
assert summary.actor_user == user
def test_empty_rcm1149_backfilled_when_rdg302_arrives_later(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\s.shelkovaya"
internal_ip = ws.ipv4
login = _ingest(
db_session,
t0 + timedelta(seconds=1),
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP connection (RCM 1149)",
summary="RCM 1149 - 1.2.3.4",
details={"user": "-", "event_id_windows": 1149},
)
assert login.details["user"] == "-"
_ingest(
db_session,
t0,
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdg.connection.success",
category="auth",
severity="info",
title="RDG 302",
summary="302",
details={"user": user, "internal_ip": internal_ip},
)
db_session.refresh(login)
assert login.details["user"] == user
def test_empty_user_login_still_closed_by_rdg303(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\s.shelkovaya"
internal_ip = ws.ipv4
login = _ingest(
db_session,
t0,
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP connection (RCM 1149)",
summary="RCM 1149",
details={"user": "-", "event_id_windows": 1149},
)
end = _ingest(
db_session,
t0 + timedelta(minutes=5),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details={"user": user, "internal_ip": internal_ip},
)
db_session.refresh(login)
assert login.details.get(SESSION_CLOSED_BY_RDG_AT_KEY) == end.occurred_at.isoformat()
assert event_session_terminated(login, db=db_session) is True
+229
View File
@@ -0,0 +1,229 @@
"""Tests for direct RDP logoff → workstation rdp.login.success correlation."""
import uuid
from datetime import datetime, timedelta, timezone
from app.services.event_summary import event_to_summary
from app.services.host_sessions import event_session_terminated
from app.services.ingest import ingest_event
from app.services.rdp_session_logoff import (
SESSION_CLOSED_BY_LOGOFF_AT_KEY,
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY,
close_workstation_session_for_rdp_logoff,
find_logoff_after_workstation_login,
resolve_workstation_login_closed_by_logoff,
)
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
base.update(overrides)
return base
def _ingest(db, occurred_at: datetime, **overrides):
payload = _payload(**overrides)
payload["occurred_at"] = occurred_at.isoformat()
event, _ = ingest_event(db, payload)
db.flush()
return event
def test_logoff_marks_workstation_login_closed_on_ingest(db_session):
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0 + timedelta(seconds=1),
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={
"user": user,
"ip_address": "192.168.160.3",
"logon_type": 10,
"event_id_windows": 4634,
},
)
assert login.details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] == logoff.occurred_at.isoformat()
assert login.details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] == logoff.id
assert event_session_terminated(login, db=db_session) is True
assert event_to_summary(login, db_session).session_terminated is True
def test_user_mismatch_does_not_close_login(db_session):
t0 = datetime.now(timezone.utc)
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": r"B26\Alice"},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={"user": r"B26\Bob", "logon_type": 10, "event_id_windows": 4634},
)
db_session.refresh(login)
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
assert event_session_terminated(login, db=db_session) is False
def test_ip_mismatch_does_not_close_login_when_both_ips_present(db_session):
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={"user": user, "ip_address": "192.168.160.99", "logon_type": 10, "event_id_windows": 4634},
)
db_session.refresh(login)
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
assert event_session_terminated(login, db=db_session) is False
def test_runtime_resolve_for_historical_login_without_flag(db_session):
from app.models import Event, Host
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = Host(
hostname="BIV-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.165.39",
)
db_session.add(host)
db_session.commit()
login = Event(
event_id=str(uuid.uuid4()),
host_id=host.id,
occurred_at=t0 + timedelta(seconds=1),
received_at=t0,
category="auth",
type="rdp.login.success",
severity="info",
title="RDP login",
summary="4624",
payload={},
details={"user": "papatramp", "ip_address": "192.168.160.3"},
)
logoff = Event(
event_id=str(uuid.uuid4()),
host_id=host.id,
occurred_at=t0 + timedelta(hours=2),
received_at=t0,
category="auth",
type="rdp.session.logoff",
severity="info",
title="4634",
summary="4634",
payload={},
details={"user": user, "ip_address": "192.168.160.3", "event_id_windows": 4634},
)
db_session.add_all([login, logoff])
db_session.commit()
assert resolve_workstation_login_closed_by_logoff(db_session, login) is True
assert find_logoff_after_workstation_login(db_session, login) is not None
assert event_to_summary(login, db_session).session_terminated is True
def test_sam_domain_user_match_on_logoff(db_session):
t0 = datetime.now(timezone.utc)
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": r"B26\papatramp", "logon_type": 10},
)
_ingest(
db_session,
t0 + timedelta(minutes=30),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4647",
details={"user": "papatramp", "logon_type": 10, "event_id_windows": 4647},
)
db_session.refresh(login)
assert event_session_terminated(login, db=db_session) is True
+20
View File
@@ -0,0 +1,20 @@
"""Tests for session_duration_sec extract/format."""
from app.services.session_duration import extract_session_duration_sec, format_session_duration
def test_extract_session_duration_sec():
assert extract_session_duration_sec({"session_duration_sec": 18324}) == 18324
assert extract_session_duration_sec({"session_duration_sec": "42"}) == 42
assert extract_session_duration_sec({"session_duration_sec": ""}) is None
assert extract_session_duration_sec({}) is None
assert extract_session_duration_sec(None) is None
assert extract_session_duration_sec({"session_duration_sec": -1}) is None
def test_format_session_duration():
assert format_session_duration(0) == "00:00:00"
assert format_session_duration(18324) == "05:05:24"
assert format_session_duration(2428) == "00:40:28"
assert format_session_duration(86400 + 85) == "1д 00:01:25"
assert format_session_duration(2 * 86400 + 3661) == "2д 01:01:01"
+2 -2
View File
@@ -1,4 +1,4 @@
"""SSH connect service tests (paramiko mocked via sys.modules).""" """SSH connect service tests (paramiko mocked via sys.modules)."""
import sys import sys
from unittest.mock import MagicMock from unittest.mock import MagicMock
@@ -323,7 +323,7 @@ def test_run_ssh_monitor_update_runs_script(monkeypatch):
target="ubabuba", target="ubabuba",
user="root", user="root",
password="pw", password="pw",
repo_url="https://git.kalinamall.ru/PapaTramp/ssh-monitor.git", repo_url="https://git.papatramp.ru/PapaTramp/ssh-monitor.git",
) )
assert result.ok is True assert result.ok is True
assert result.agent_version == "2.1.0-SAC" assert result.agent_version == "2.1.0-SAC"
+3
View File
@@ -7,6 +7,8 @@ DATABASE_URL=postgresql+psycopg2://sac:CHANGE_ME_POSTGRES_PASSWORD@127.0.0.1:543
# SQLAlchemy pool (uvicorn workers × concurrent ingest). Было по умолчанию 5+10 — мало для штурма 09:00. # SQLAlchemy pool (uvicorn workers × concurrent ingest). Было по умолчанию 5+10 — мало для штурма 09:00.
SAC_DB_POOL_SIZE=15 SAC_DB_POOL_SIZE=15
SAC_DB_MAX_OVERFLOW=25 SAC_DB_MAX_OVERFLOW=25
# Uvicorn workers (ingest burst). Читает deploy/systemd/sac-api-start.sh; при >1 отключите in-process host_silence scan ниже.
SAC_UVICORN_WORKERS=4
SAC_PUBLIC_URL=https://sac.kalinamall.ru SAC_PUBLIC_URL=https://sac.kalinamall.ru
# Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS). # Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS).
@@ -128,6 +130,7 @@ SAC_MOBILE_REFRESH_EXPIRE_DAYS=90
CORS_ORIGINS=https://sac.kalinamall.ru CORS_ORIGINS=https://sac.kalinamall.ru
# SSH: verify host keys (RejectPolicy). Add keys to file before remote actions. # SSH: verify host keys (RejectPolicy). Add keys to file before remote actions.
# Комментарий — только отдельной строкой с #. Нельзя: SAC_SSH_AUTO_ADD_HOST_KEY=false ← текст
# SAC_SSH_KNOWN_HOSTS_FILE=/opt/security-alert-center/config/ssh_known_hosts # SAC_SSH_KNOWN_HOSTS_FILE=/opt/security-alert-center/config/ssh_known_hosts
# SAC_SSH_AUTO_ADD_HOST_KEY=false # SAC_SSH_AUTO_ADD_HOST_KEY=false
+13
View File
@@ -44,6 +44,19 @@ server {
proxy_read_timeout 960s; proxy_read_timeout 960s;
} }
# Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem.
location = /api/v1/events {
proxy_pass http://sac_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location / { location / {
proxy_pass http://sac_api; proxy_pass http://sac_api;
proxy_http_version 1.1; proxy_http_version 1.1;
+13
View File
@@ -69,6 +69,19 @@ server {
proxy_read_timeout 960s; proxy_read_timeout 960s;
} }
# Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem.
location = /api/v1/events {
proxy_pass http://sac_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location / { location / {
proxy_pass http://sac_api; proxy_pass http://sac_api;
proxy_http_version 1.1; proxy_http_version 1.1;
+27 -9
View File
@@ -56,12 +56,16 @@ fi
log "pip install -r requirements.txt" log "pip install -r requirements.txt"
sudo -u "${APP_USER}" "${VENV}/bin/pip" install -q -r "${APP_ROOT}/backend/requirements.txt" sudo -u "${APP_USER}" "${VENV}/bin/pip" install -q -r "${APP_ROOT}/backend/requirements.txt"
log "Проверка ${CONFIG_FILE} (pydantic; без bash source — inline-комментарии в значениях ломают deploy)"
sudo -u "${APP_USER}" bash -c "
export SAC_CONFIG_FILE='${CONFIG_FILE}'
cd '${APP_ROOT}/backend'
'${VENV}/bin/python' -c 'from app.config import get_settings; get_settings(); print(\"config: OK\")'
" || die "Неверный ${CONFIG_FILE}: одна переменная = одна строка, комментарии только отдельной строкой с # (не «false ← …» после значения)"
log "alembic upgrade head" log "alembic upgrade head"
sudo -u "${APP_USER}" bash -c " sudo -u "${APP_USER}" bash -c "
set -a export SAC_CONFIG_FILE='${CONFIG_FILE}'
# shellcheck source=/dev/null
source '${CONFIG_FILE}'
set +a
cd '${APP_ROOT}/backend' && '${VENV}/bin/alembic' upgrade head cd '${APP_ROOT}/backend' && '${VENV}/bin/alembic' upgrade head
" "
@@ -89,6 +93,16 @@ if [ -f "${APP_ROOT}/deploy/systemd/${SERVICE_NAME}.service" ]; then
systemctl daemon-reload systemctl daemon-reload
fi fi
fi fi
START_SH="${APP_ROOT}/deploy/systemd/sac-api-start.sh"
if [ -f "${START_SH}" ]; then
sed -i 's/\r$//' "${START_SH}"
chmod 755 "${START_SH}"
fi
for _sh in "${APP_ROOT}"/deploy/sac-deploy.sh "${APP_ROOT}"/deploy/systemd/*.sh; do
[ -f "${_sh}" ] || continue
sed -i 's/\r$//' "${_sh}"
chmod 755 "${_sh}" 2>/dev/null || true
done
log "Проверка DATABASE_URL (как у uvicorn через SAC_CONFIG_FILE)" log "Проверка DATABASE_URL (как у uvicorn через SAC_CONFIG_FILE)"
sudo -u "${APP_USER}" bash -c " sudo -u "${APP_USER}" bash -c "
@@ -107,10 +121,7 @@ print('db: OK')
log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)" log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)"
log "Сброс зависших remote_action (running без worker после restart)" log "Сброс зависших remote_action (running без worker после restart)"
sudo -u "${APP_USER}" bash -c " sudo -u "${APP_USER}" bash -c "
set -a export SAC_CONFIG_FILE='${CONFIG_FILE}'
# shellcheck source=/dev/null
source '${CONFIG_FILE}'
set +a
cd '${APP_ROOT}/backend' cd '${APP_ROOT}/backend'
'${VENV}/bin/python' -c \" '${VENV}/bin/python' -c \"
from app.database import SessionLocal from app.database import SessionLocal
@@ -125,7 +136,14 @@ finally:
\" \"
" "
systemctl restart "${SERVICE_NAME}" systemctl restart "${SERVICE_NAME}"
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active" sleep 2
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if systemctl is-active --quiet "${SERVICE_NAME}"; then
break
fi
sleep 1
done
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active — journalctl -u ${SERVICE_NAME} -n 40 --no-pager"
HEALTH_OK=0 HEALTH_OK=0
for _ in 1 2 3 4 5 6; do for _ in 1 2 3 4 5 6; do
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Uvicorn launcher: SAC_UVICORN_WORKERS из sac-api.env (без systemd EnvironmentFile).
set -euo pipefail
APP_ROOT="${SAC_APP_ROOT:-/opt/security-alert-center}"
CONFIG_FILE="${SAC_CONFIG_FILE:-${APP_ROOT}/config/sac-api.env}"
VENV="${APP_ROOT}/backend/.venv"
HOST="127.0.0.1"
PORT="8000"
WORKERS=4
_read_env_int() {
local key="$1" default="$2" line val
[ -f "$CONFIG_FILE" ] || {
printf '%s\n' "$default"
return 0
}
line="$(grep -E "^[[:space:]]*${key}=" "$CONFIG_FILE" 2>/dev/null | tail -1)" || {
printf '%s\n' "$default"
return 0
}
val="${line#*=}"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
val="${val#\"}"
val="${val%\"}"
val="${val#\'}"
val="${val%\'}"
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
printf '%s\n' "$val"
else
printf '%s\n' "$default"
fi
}
WORKERS="$(_read_env_int SAC_UVICORN_WORKERS 4)"
exec "${VENV}/bin/uvicorn" app.main:app \
--host "$HOST" \
--port "$PORT" \
--workers "$WORKERS" \
--timeout-graceful-shutdown 30
+2 -2
View File
@@ -1,6 +1,6 @@
[Unit] [Unit]
Description=Security Alert Center API (FastAPI) Description=Security Alert Center API (FastAPI)
Documentation=https://git.kalinamall.ru/PapaTramp/security-alert-center Documentation=https://git.papatramp.ru/PapaTramp/security-alert-center
After=network-online.target postgresql.service After=network-online.target postgresql.service
Wants=network-online.target Wants=network-online.target
Requires=postgresql.service Requires=postgresql.service
@@ -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 --workers 2 --timeout-graceful-shutdown 30 ExecStart=/usr/bin/bash /opt/security-alert-center/deploy/systemd/sac-api-start.sh
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
TimeoutStopSec=20 TimeoutStopSec=20
+7 -5
View File
@@ -183,11 +183,13 @@ sequenceDiagram
## 5. П.2C — Доступ SAC к хостам ## 5. П.2C — Доступ SAC к хостам
### 5.1. Windows — WinRM ### 5.1. Windows — WinRM / учётки
- **Один доменный admin** в **Настройки → Управление хостами → Windows**: `DOMAIN\user` + password (encrypted). - **Глобальный доменный admin** в **Настройки → Windows**: `DOMAIN\user` + password (encrypted). Аналогично **Linux admin** для SSH.
- Override на карточке хоста — опционально позже. - **Override на карточке хоста** (**Хосты → WinRM/SSH / доступ к хосту**): `COMPUTER\user` / `.\Administrator` + password (encrypted в `hosts.mgmt_*`). Если пусто — берутся глобальные.
- Используется для fallback-обновления и (при необходимости) remote ops; для qwinsta/logoff MVP — **через агента** с теми же creds, переданными в command poll (TLS + API key, не пишутся на диск агента). - Effective creds: `get_effective_win_admin_for_host` / `get_effective_linux_admin_for_host` (source: `host` | `db` | `env`).
- API: `GET` / `PUT /api/v1/hosts/{id}/access` (`user`, `password`, `clear`).
- Используются для WinRM/SSH-test, fallback-update, qwinsta/logoff, remote ops; для run_as через агента — те же creds в command poll (TLS + API key, не пишутся на диск агента).
### 5.2. Linux — bootstrap password → SSH key → удалить password ### 5.2. Linux — bootstrap password → SSH key → удалить password
@@ -266,7 +268,7 @@ Authorization: Bearer sac_xxx
| POST | `/api/v1/events/{id}/actions/logoff` | logoff `{ "session_id": 5 }` | | POST | `/api/v1/events/{id}/actions/logoff` | logoff `{ "session_id": 5 }` |
| GET | `/api/v1/events/{id}/actions/{cmd_id}` | Статус / результат | | GET | `/api/v1/events/{id}/actions/{cmd_id}` | Статус / результат |
| PATCH | `/api/v1/hosts/{id}/config` | Desired config | | PATCH | `/api/v1/hosts/{id}/config` | Desired config |
| PATCH | `/api/v1/hosts/{id}/access` | Bootstrap / WinRM creds | | GET/PUT | `/api/v1/hosts/{id}/access` | Per-host WinRM/SSH override (mgmt_user/password) |
| GET/PATCH | `/api/v1/settings/agent-updates` | Режим A/B, версии, источники | | GET/PATCH | `/api/v1/settings/agent-updates` | Режим A/B, версии, источники |
| GET/PATCH | `/api/v1/settings/host-management` | Доменный admin Windows | | GET/PATCH | `/api/v1/settings/host-management` | Доменный admin Windows |
+1
View File
@@ -185,6 +185,7 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|---------|--------|------------| |---------|--------|------------|
| 4624 успех | `rdp.login.success` | info | | 4624 успех | `rdp.login.success` | info |
| 4625 неудача | `rdp.login.failed` | warning | | 4625 неудача | `rdp.login.failed` | warning |
| 4634 / 4647 выход (прямой RDP, **только рабочая станция**) | `rdp.session.logoff` | info |
| RCM **20506** Shadow Control started | `rdp.shadow.control.started` | **warning** | | RCM **20506** Shadow Control started | `rdp.shadow.control.started` | **warning** |
| RCM **20507** Shadow Control stopped | `rdp.shadow.control.stopped` | **warning** | | RCM **20507** Shadow Control stopped | `rdp.shadow.control.stopped` | **warning** |
| RCM **20510** Shadow Control permission | `rdp.shadow.control.permission` | **warning** | | RCM **20510** Shadow Control permission | `rdp.shadow.control.permission` | **warning** |
+1 -1
View File
@@ -115,7 +115,7 @@ sudo /opt/sac-deploy.sh
Краткий **502 Bad Gateway** в UI (~10 с) возможен при перезапуске `sac-api` — список хостов автоматически повторяет запрос; после деплоя обновите страницу. Краткий **502 Bad Gateway** в UI (~10 с) возможен при перезапуске `sac-api` — список хостов автоматически повторяет запрос; после деплоя обновите страницу.
**Важно в `sac-api.env`:** `SAC_PUBLIC_URL=https://sac.kalinamall.ru` — нужен для WinRM-обновления RDP (клиент скачивает zip с SAC). API: **2 worker** uvicorn (`deploy/systemd/sac-api.service`). **Важно в `sac-api.env`:** `SAC_PUBLIC_URL=https://sac.kalinamall.ru` — нужен для WinRM-обновления RDP (клиент скачивает zip с SAC). API: **4 worker** uvicorn по умолчанию (`SAC_UVICORN_WORKERS`, `deploy/systemd/sac-api-start.sh`).
Установка скрипта (один раз): `sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh && sudo chmod 755 /opt/sac-deploy.sh` Установка скрипта (один раз): `sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh && sudo chmod 755 /opt/sac-deploy.sh`
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.kalinamall.ru/PapaTramp/security-alert-center/schemas/event-v1.json", "$id": "https://git.papatramp.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
"title": "Security Alert Center Event v1", "title": "Security Alert Center Event v1",
"description": "Каноническое событие от ssh-monitor или RDP-login-monitor", "description": "Каноническое событие от ssh-monitor или RDP-login-monitor",
"type": "object", "type": "object",
@@ -94,6 +94,7 @@
"session.logind.new", "session.logind.new",
"rdp.login.success", "rdp.login.success",
"rdp.login.failed", "rdp.login.failed",
"rdp.session.logoff",
"rdp.shadow.control.started", "rdp.shadow.control.started",
"rdp.shadow.control.stopped", "rdp.shadow.control.stopped",
"rdp.shadow.control.permission", "rdp.shadow.control.permission",
+50
View File
@@ -0,0 +1,50 @@
# Backlog: ingest и массовое обновление агентов
**Статус:** ToDo (не в текущем релизе).
**Контекст:** массовый SSH-update через SAC (10+ хостов) + `sac-deploy` в одно окно → часть POST в ingest «теряется» с точки зрения агента (`SAC POST HTTP :`), flood в Telegram (fallback + watchdog).
**Связано:** [runbook-ops.md](runbook-ops.md), [agent-integration.md](agent-integration.md), ssh-monitor [security-roadmap.ru.md](https://git.papatramp.ru/PapaTramp/ssh-monitor/src/branch/main/docs/security-roadmap.ru.md).
Увеличение `SAC_DB_POOL_SIZE` / defer daily в **0.5.0** лечит штурм суточных отчётов и пул БД; **не** снимает узкие места ниже при одновременном restart многих агентов.
---
## Операционно (сразу, без кода)
- [ ] **Не совмещать** `sudo /opt/sac-deploy.sh` и массовое «Обновить ssh-monitor (SSH)» по многим хостам — сначала deploy SAC, потом агенты (или наоборот).
- [ ] Обновлять Linux-хосты **пачками по 23**, не все подряд.
- [ ] На зрелых хостах перевести **`UseSAC=exclusive`** (нет дубля в Telegram с агента при fallback); watchdog по-прежнему шлёт в Telegram сам.
- [ ] Перед первым SSH-update с SAC: **`ssh-keyscan`** в `config/ssh_known_hosts` (см. runbook).
- [ ] В `sac-api.env`: только `KEY=value` на строку, комментарии отдельной строкой с `#`.
---
## SAC (backend / deploy)
- [x] **Defer** `notify_lifecycle` и `notify_auth_login` в background (как `report.daily.*``schedule_notify_daily_report`), чтобы ingest не ждал Telegram API — **0.5.5**
- [x] **Uvicorn workers:** 4 по умолчанию или `SAC_UVICORN_WORKERS` в `sac-api.env` / `sac-api-start.sh` — **0.5.5**
- [x] **nginx:** отдельный `location` для `POST /api/v1/events` с увеличенным `proxy_read_timeout` — **0.5.5**
- [ ] Опционально: метрики/лог длительности ingest и очереди при burst.
- [x] UI: предупреждение при массовом update («N хостов — рекомендуется пачками») — **0.5.5**
---
## ssh-monitor (агент)
- [x] При **shutdown** / `SIGTERM` не увеличивать `sac-fail.count` — **2.3.2-SAC**
- [x] После успешного heartbeat сбрасывать fail counter (успешный POST ingest) — уже было
- [x] Watchdog: не слать Telegram при штатном restart во время SAC-update (state file `/var/lib/ssh-monitor/agent-update-in-progress` от updater) — **2.2.1-SAC**
- [x] Sudo bootstrap/update через SAC: не слать Telegram (`ssh_monitor_sudo_is_sac_maintenance`, state-file раньше) — **2.2.3-SAC**
- [x] SAC: suppress `privilege.sudo.command` при `agent_update_state=running` / maintenance command — **0.5.3**
- [x] Документировать рекомендуемый `SAC_TIMEOUT_SEC` при тяжёлом ingest — **2.3.2-SAC** (`docs/sac-ingest.ru.md`)
---
## Принято / сделано
- [x] Pool БД 15+25, defer daily push — SAC **0.5.0**
- [x] `sac-deploy.sh` без `source` конфига, preflight pydantic — SAC `fa1bd41`
- [x] Watchdog: строка `🖥️ Сервер:` в Telegram — ssh-monitor **2.1.9-SAC**
---
*После реализации пунктов SAC — bump `APP_VERSION` и runbook.*
+1 -1
View File
@@ -24,7 +24,7 @@ Docker удобен для локальной отладки или изолир
```bash ```bash
sudo apt install -y git sudo apt install -y git
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
sudo chown -R "$USER:$USER" /opt/security-alert-center sudo chown -R "$USER:$USER" /opt/security-alert-center
``` ```
+1 -1
View File
@@ -71,7 +71,7 @@ nginx -v
```bash ```bash
sudo mkdir -p /opt sudo mkdir -p /opt
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
``` ```
--- ---
+1 -1
View File
@@ -14,7 +14,7 @@
```bash ```bash
# см. полный чеклист в native-руководстве # см. полный чеклист в native-руководстве
sudo apt update && sudo apt install -y git postgresql nginx python3.12 python3.12-venv sudo apt update && sudo apt install -y git postgresql nginx python3.12 python3.12-venv
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
# … PostgreSQL, venv, config/sac-api.env, systemd, nginx # … PostgreSQL, venv, config/sac-api.env, systemd, nginx
``` ```
+1 -1
View File
@@ -39,7 +39,7 @@ sudo /opt/sac-deploy.sh
| Параметр | Значение | | Параметр | Значение |
|----------|----------| |----------|----------|
| Хост | `ubabuba` / `10.10.36.9` | | Хост | `ubabuba` / `10.10.36.9` |
| Репозиторий | `git.kalinamall.ru/PapaTramp/ssh-monitor` (`main`, есть `sac-client.sh`) | | Репозиторий | `git.papatramp.ru/PapaTramp/ssh-monitor` (`main`, есть `sac-client.sh`) |
| `UseSAC` | пилот **exclusive** — см. [pilot-2.1-exclusive.md](pilot-2.1-exclusive.md) | | `UseSAC` | пилот **exclusive** — см. [pilot-2.1-exclusive.md](pilot-2.1-exclusive.md) |
| Сервис | `ssh-monitor.service`**active** | | Сервис | `ssh-monitor.service`**active** |
| `--check-sac` | OK (health + ingest `agent.test` 202) | | `--check-sac` | OK (health + ingest `agent.test` 202) |
+3 -3
View File
@@ -66,7 +66,7 @@
**Цель:** нативный Android-клиент и push по тем же правилам оповещений, что Telegram/email/webhook. **Цель:** нативный Android-клиент и push по тем же правилам оповещений, что Telegram/email/webhook.
Репозиторий клиента: [seaca](https://git.kalinamall.ru/PapaTramp/seaca) (Kotlin, Jetpack Compose, FCM). Репозиторий клиента: [seaca](https://git.papatramp.ru/PapaTramp/seaca) (Kotlin, Jetpack Compose, FCM).
**Сервер SAC:** **Сервер SAC:**
@@ -77,7 +77,7 @@
**Не в мобильном клиенте:** пользователи SAC, каналы Telegram/SMTP/webhook, админ-настройки. **Не в мобильном клиенте:** пользователи SAC, каналы Telegram/SMTP/webhook, админ-настройки.
Подробный план клиента — [seaca/docs/ROADMAP.md](https://git.kalinamall.ru/PapaTramp/seaca/src/branch/main/docs/ROADMAP.md). Подробный план клиента — [seaca/docs/ROADMAP.md](https://git.papatramp.ru/PapaTramp/seaca/src/branch/main/docs/ROADMAP.md).
--- ---
@@ -112,7 +112,7 @@
| v0.1 | — (не требуется) | — | | v0.1 | — (не требуется) | — |
| v0.2 | TBD (тег с UseSAC) | TBD | | v0.2 | TBD (тег с UseSAC) | TBD |
| v0.3+ | совместимость schema 1.0 | совместимость schema 1.0 | | v0.3+ | совместимость schema 1.0 | совместимость schema 1.0 |
| v0.6 | — | — (мобильный клиент [seaca](https://git.kalinamall.ru/PapaTramp/seaca), не агент) | | v0.6 | — | — (мобильный клиент [seaca](https://git.papatramp.ru/PapaTramp/seaca), не агент) |
При breaking change схемы — `schema_version: 1.1` с поддержкой 1.0 на ingest. При breaking change схемы — `schema_version: 1.1` с поддержкой 1.0 на ingest.
+26
View File
@@ -18,6 +18,32 @@ curl -sS https://sac.kalinamall.ru/health | jq .
Ожидается `status: ok`, `database: ok`. При устаревших heartbeat агентов — `status: degraded`, поле `hosts_stale` > 0. Ожидается `status: ok`, `database: ok`. При устаревших heartbeat агентов — `status: degraded`, поле `hosts_stale` > 0.
### `sac-api.env`: формат строк
Файл читается **pydantic** (`SAC_CONFIG_FILE`), не как произвольный bash-скрипт.
- Одна переменная — одна строка: `SAC_SSH_AUTO_ADD_HOST_KEY=false`
- Комментарии — **отдельной** строкой с `#` в начале
- **Нельзя** inline после значения: `false ← так и оставляем` — deploy упадёт на `alembic` с `bool_parsing`
Проверка без деплоя:
```bash
sudo -u sac bash -c 'export SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env; cd /opt/security-alert-center/backend && .venv/bin/python -c "from app.config import get_settings; get_settings(); print(\"OK\")"'
```
### Linux SSH update: `known_hosts`
Перед «Обновить ssh-monitor (SSH)» ключ хоста должен быть в файле (по умолчанию `config/ssh_known_hosts`):
```bash
ssh-keyscan -H 10.10.7.2 | sudo tee -a /opt/security-alert-center/config/ssh_known_hosts
sudo chown sac:sac /opt/security-alert-center/config/ssh_known_hosts
sudo chmod 600 /opt/security-alert-center/config/ssh_known_hosts
```
Иначе SAC: `Server '…' not found in known_hosts`. `SAC_SSH_AUTO_ADD_HOST_KEY=true` для prod не рекомендуется.
## Мобильные устройства (Seaca) ## Мобильные устройства (Seaca)
См. [seaca-mobile.md](seaca-mobile.md) и [seaca-fcm.md](seaca-fcm.md). См. [seaca-mobile.md](seaca-mobile.md) и [seaca-fcm.md](seaca-fcm.md).
+2 -2
View File
@@ -1,6 +1,6 @@
# Seaca — мобильный клиент SAC # Seaca — мобильный клиент SAC
Репозиторий приложения: [seaca](https://git.kalinamall.ru/PapaTramp/seaca). Репозиторий приложения: [seaca](https://git.papatramp.ru/PapaTramp/seaca).
Требуется SAC **≥ 0.9.0** с применённой миграцией `014`. Требуется SAC **≥ 0.9.0** с применённой миграцией `014`.
--- ---
@@ -84,7 +84,7 @@ SAC_FCM_SERVICE_ACCOUNT_JSON=/etc/security-alert-center/fcm-service-account.json
| `sac.kalinamall.ru` | Web UI | yes (`git-sac-allowed`) | | `sac.kalinamall.ru` | Web UI | yes (`git-sac-allowed`) |
| `sac-api.kalinamall.ru` | Seaca, `/api/v1/mobile/*` | no (enroll + JWT) | | `sac-api.kalinamall.ru` | Seaca, `/api/v1/mobile/*` | no (enroll + JWT) |
Both resolve to the same SAC server (**192.168.160.145**). nginx must accept both names in `server_name`. See [reverse-proxy/docs/sac-access.md](https://git.kalinamall.ru/PapaTramp/reverse-proxy/src/branch/main/docs/sac-access.md). Both resolve to the same SAC server (**192.168.160.145**). nginx must accept both names in `server_name`. See [reverse-proxy/docs/sac-access.md](https://git.papatramp.ru/PapaTramp/reverse-proxy/src/branch/main/docs/sac-access.md).
--- ---
+6 -6
View File
@@ -8,9 +8,9 @@
| Имя в workspace | Путь (пример Windows) | Remote | | Имя в workspace | Путь (пример Windows) | Remote |
|-----------------|----------------------|--------| |-----------------|----------------------|--------|
| `ssh-monitor` | `D:\Soft\Git\ssh-monitor` | git.kalinamall.ru/PapaTramp/ssh-monitor | | `ssh-monitor` | `D:\Soft\Git\ssh-monitor` | git.papatramp.ru/PapaTramp/ssh-monitor |
| `rdp-monitor` | `D:\Soft\Git\RDP-login-monitor` | git.kalinamall.ru/PapaTramp/RDP-login-monitor | | `rdp-monitor` | `D:\Soft\Git\RDP-login-monitor` | git.papatramp.ru/PapaTramp/RDP-login-monitor |
| `security-alert-center` | `D:\Soft\Git\security-alert-center` | git.kalinamall.ru/PapaTramp/security-alert-center | | `security-alert-center` | `D:\Soft\Git\security-alert-center` | git.papatramp.ru/PapaTramp/security-alert-center |
Пути подставьте свои. Пути подставьте свои.
@@ -65,9 +65,9 @@ git push kalinamall main
| Проект | URL | | Проект | URL |
|--------|-----| |--------|-----|
| security-alert-center | `https://git.kalinamall.ru/PapaTramp/security-alert-center.git` | | security-alert-center | `https://git.papatramp.ru/PapaTramp/security-alert-center.git` |
| ssh-monitor | `https://git.kalinamall.ru/PapaTramp/ssh-monitor.git` | | ssh-monitor | `https://git.papatramp.ru/PapaTramp/ssh-monitor.git` |
| RDP-login-monitor | `https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git` | | RDP-login-monitor | `https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git` |
--- ---
+29 -1
View File
@@ -192,6 +192,7 @@ export interface EventSummary {
title: string; title: string;
summary: string; summary: string;
actor_user?: string | null; actor_user?: string | null;
session_duration_sec?: number | null;
rdg_flap?: boolean; rdg_flap?: boolean;
rdg_flap_pair_event_id?: number | null; rdg_flap_pair_event_id?: number | null;
rdg_flap_qwinsta_event_id?: number | null; rdg_flap_qwinsta_event_id?: number | null;
@@ -531,6 +532,31 @@ export function updateLinuxAdminSettings(payload: {
}); });
} }
export interface HostMgmtAccess {
host_id: number;
has_override: boolean;
user: string | null;
password_set: boolean;
password_hint: string | null;
effective_source: string;
effective_configured: boolean;
effective_user: string | null;
}
export function fetchHostAccess(hostId: number): Promise<HostMgmtAccess> {
return apiFetch<HostMgmtAccess>(`/api/v1/hosts/${hostId}/access`);
}
export function updateHostAccess(
hostId: number,
payload: { user?: string | null; password?: string | null; clear?: boolean },
): Promise<HostMgmtAccess> {
return apiFetch<HostMgmtAccess>(`/api/v1/hosts/${hostId}/access`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
export interface HostSshActionResult { export interface HostSshActionResult {
ok: boolean; ok: boolean;
message: string; message: string;
@@ -613,7 +639,9 @@ export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteA
} }
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> { export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`); return apiFetch<HostRemoteActionJobStatus>(
`/api/v1/hosts/${hostId}/actions/remote-job?_ts=${Date.now()}`,
);
} }
export interface HostSessionItem { export interface HostSessionItem {
+269 -16
View File
@@ -1,31 +1,60 @@
<template> <template>
<div v-if="open" class="host-action-log-dock" aria-live="polite"> <div v-if="open" class="host-action-log-dock" aria-live="polite">
<div class="host-action-log-panel" role="dialog" :aria-label="title"> <div
class="host-action-log-panel"
:class="{ 'host-action-log-panel-done': !isLoading && ok === true, 'host-action-log-panel-fail': !isLoading && ok === false }"
role="dialog"
:aria-label="displayTitle"
>
<div class="host-action-log-header"> <div class="host-action-log-header">
<h3>{{ title }}</h3> <h3>
<span v-if="!isLoading && ok === true" class="host-action-log-mark host-action-log-mark-ok" aria-hidden="true"></span>
<span v-else-if="!isLoading && ok === false" class="host-action-log-mark host-action-log-mark-fail" aria-hidden="true"></span>
{{ displayTitle }}
</h3>
<button <button
type="button" type="button"
class="host-action-log-icon-btn" class="host-action-log-icon-btn"
:title="loading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'" :title="isLoading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'"
:aria-label="loading ? 'Свернуть' : 'Закрыть'" :aria-label="isLoading ? 'Свернуть' : 'Закрыть'"
@click="emit('close')" @click="emit('close')"
> >
× ×
</button> </button>
</div> </div>
<p v-if="loading" class="host-action-log-status"> <p v-if="isLoading" class="host-action-log-status">
<span class="host-action-log-spinner" aria-hidden="true" /> <span class="host-action-log-spinner" aria-hidden="true" />
Выполняется на удалённом хосте Можно запустить обновление других хостов. Выполняется на удалённом хосте Можно запустить обновление других хостов.
</p> </p>
<p v-if="message && !loading" class="host-action-log-message" :class="messageClass">{{ message }}</p> <p
<p v-else-if="message && loading" class="muted host-action-log-sub host-action-log-message">{{ message }}</p> v-else-if="ok === true"
<pre v-if="output" class="host-action-log-output">{{ output }}</pre> class="host-action-log-message success host-action-log-result"
>
{{ displayMessage }}
</p>
<p
v-else-if="ok === false"
class="host-action-log-message error host-action-log-result"
>
{{ displayMessage }}
</p>
<p v-else-if="displayMessage && isLoading" class="muted host-action-log-sub host-action-log-message">
{{ displayMessage }}
</p>
<p v-if="!isLoading && ok === true && autoCloseSec > 0" class="muted host-action-log-sub">
Окно закроется автоматически через {{ autoCloseSec }} с
</p>
<pre
v-show="logVisible"
ref="logPreRef"
class="host-action-log-output"
>{{ displayOutput }}</pre>
<div class="host-action-log-actions"> <div class="host-action-log-actions">
<button v-if="loading" type="button" class="secondary" @click="emit('close')"> <button type="button" class="secondary" @click="emit('toggle-log')">
Свернуть {{ logVisible ? "Скрыть лог" : "Показать лог" }}
</button> </button>
<button v-else type="button" class="secondary" @click="emit('close')"> <button type="button" class="secondary" @click="emit('close')">
Закрыть {{ isLoading ? "Свернуть" : "Закрыть" }}
</button> </button>
</div> </div>
</div> </div>
@@ -33,24 +62,210 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { fetchHostRemoteJob, type HostRemoteActionJobStatus } from "../api";
const LOG_POLL_MS = 1500;
const AUTO_CLOSE_MS = 30_000;
const props = defineProps<{ const props = defineProps<{
hostId: number;
open: boolean; open: boolean;
title: string; title: string;
loading: boolean; loading: boolean;
ok: boolean | null; ok: boolean | null;
message: string; message: string;
output: string; output: string;
logPlaceholder: string;
logVisible: boolean;
sessionStartedAt: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
close: []; close: [];
"toggle-log": [];
finished: [job: HostRemoteActionJobStatus];
}>(); }>();
const messageClass = computed(() => { const logPreRef = ref<HTMLElement | null>(null);
if (props.loading || props.ok == null) return "muted"; const polledOutput = ref("");
return props.ok ? "success" : "error"; const polledMessage = ref("");
const isLoading = ref(true);
const ok = ref<boolean | null>(null);
const localTitle = ref("");
const localMessage = ref("");
const autoCloseSec = ref(0);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let countdownTimer: ReturnType<typeof setInterval> | null = null;
let finishedEmitted = false;
let sawRunning = false;
function isTerminalJob(job: HostRemoteActionJobStatus): boolean {
const st = (job.status || "").toLowerCase();
return st === "success" || st === "failed";
}
function jobStartedAfterSession(job: HostRemoteActionJobStatus): boolean {
const started = job.started_at;
if (!started) return false;
const jobMs = Date.parse(started);
const sessionMs = Date.parse(props.sessionStartedAt);
if (Number.isNaN(jobMs) || Number.isNaN(sessionMs)) return false;
return jobMs >= sessionMs - 3000;
}
function isJobRunning(job: HostRemoteActionJobStatus): boolean {
if (job.active) return true;
const st = (job.status || "").toLowerCase();
if (st === "running") return true;
return (job.agent_update_state || "").toLowerCase() === "running";
}
const displayTitle = computed(() => {
if (localTitle.value) return localTitle.value;
return props.title;
});
const displayMessage = computed(() => {
if (localMessage.value) return localMessage.value;
if (polledMessage.value) return polledMessage.value;
return props.message;
});
const displayOutput = computed(() => {
const text = (polledOutput.value || props.output).trim();
return text || props.logPlaceholder;
});
function stopPoll() {
if (pollTimer != null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function stopCountdown() {
if (countdownTimer != null) {
clearInterval(countdownTimer);
countdownTimer = null;
}
autoCloseSec.value = 0;
}
function startCountdown() {
stopCountdown();
autoCloseSec.value = Math.ceil(AUTO_CLOSE_MS / 1000);
countdownTimer = setInterval(() => {
if (autoCloseSec.value <= 1) {
stopCountdown();
return;
}
autoCloseSec.value -= 1;
}, 1000);
}
function applyFinishedJob(job: HostRemoteActionJobStatus) {
isLoading.value = false;
ok.value = job.ok ?? job.status === "success";
if (job.title) localTitle.value = job.title;
if (job.message) localMessage.value = job.message;
const out = (job.output || job.stdout || "").trim();
if (out) polledOutput.value = out;
if (ok.value) startCountdown();
}
async function pollJobOnce() {
try {
const job = await fetchHostRemoteJob(props.hostId);
const out = (job.output || job.stdout || "").trim();
if (out) polledOutput.value = out;
if (job.message && isLoading.value) polledMessage.value = job.message;
if (isJobRunning(job)) {
sawRunning = true;
return;
}
const canFinish =
isTerminalJob(job) && (sawRunning || jobStartedAfterSession(job));
if (canFinish) {
if (!finishedEmitted) {
finishedEmitted = true;
applyFinishedJob(job);
emit("finished", job);
}
stopPoll();
}
} catch {
/* следующий poll */
}
}
function startPoll() {
stopPoll();
void pollJobOnce();
pollTimer = setInterval(() => void pollJobOnce(), LOG_POLL_MS);
}
function resetLocalState() {
finishedEmitted = false;
sawRunning = false;
isLoading.value = true;
ok.value = null;
localTitle.value = "";
localMessage.value = "";
polledOutput.value = props.output.trim();
polledMessage.value = "";
stopCountdown();
}
watch(
() => props.open,
(open) => {
if (open && isLoading.value) {
startPoll();
} else {
stopPoll();
}
},
{ immediate: true },
);
watch(
() => props.loading,
(loading) => {
if (loading) {
resetLocalState();
if (props.open) startPoll();
return;
}
if (!finishedEmitted && props.ok != null) {
finishedEmitted = true;
isLoading.value = false;
ok.value = props.ok;
localTitle.value = props.title;
localMessage.value = props.message;
if (props.output.trim()) polledOutput.value = props.output.trim();
if (props.ok) startCountdown();
stopPoll();
}
},
{ immediate: true },
);
watch(
() => [displayOutput.value, props.logVisible] as const,
async () => {
if (!props.logVisible) return;
await nextTick();
const el = logPreRef.value;
if (el) el.scrollTop = el.scrollHeight;
},
);
onUnmounted(() => {
stopPoll();
stopCountdown();
}); });
</script> </script>
@@ -71,6 +286,14 @@ const messageClass = computed(() => {
padding: 0.85rem 1rem 1rem; padding: 0.85rem 1rem 1rem;
} }
.host-action-log-panel-done {
border-color: #3fb950;
}
.host-action-log-panel-fail {
border-color: #f85149;
}
.host-action-log-header { .host-action-log-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -82,6 +305,22 @@ const messageClass = computed(() => {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
line-height: 1.3; line-height: 1.3;
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
.host-action-log-mark {
flex-shrink: 0;
font-weight: 700;
}
.host-action-log-mark-ok {
color: #3fb950;
}
.host-action-log-mark-fail {
color: #f85149;
} }
.host-action-log-icon-btn { .host-action-log-icon-btn {
@@ -122,6 +361,20 @@ const messageClass = computed(() => {
word-break: break-word; word-break: break-word;
} }
.host-action-log-result {
margin: 0.65rem 0 0;
font-size: 0.95rem;
font-weight: 500;
}
.host-action-log-message.success {
color: #3fb950;
}
.host-action-log-message.error {
color: #f85149;
}
.host-action-log-spinner { .host-action-log-spinner {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
+13 -4
View File
@@ -1,16 +1,23 @@
<template> <template>
<div v-if="openLogs.length" class="host-action-log-stack" aria-live="polite"> <div v-if="hasOpenLogs" class="host-action-log-stack" aria-live="polite">
<template v-for="entry in hostRemoteActionLogs" :key="entry.id">
<HostActionLogModal <HostActionLogModal
v-for="entry in openLogs" v-if="entry.open"
:key="entry.id" :host-id="entry.hostId"
:open="true" :open="true"
:title="entry.title" :title="entry.title"
:loading="entry.loading" :loading="entry.loading"
:ok="entry.ok" :ok="entry.ok"
:message="entry.message" :message="entry.message"
:output="entry.output" :output="entry.output"
:log-placeholder="entry.logPlaceholder"
:log-visible="entry.logVisible"
:session-started-at="entry.sessionStartedAt"
@close="closeHostRemoteActionLog(entry.id)" @close="closeHostRemoteActionLog(entry.id)"
@toggle-log="toggleHostRemoteActionLog(entry.id)"
@finished="syncHostRemoteActionJobFinished(entry.id, $event)"
/> />
</template>
</div> </div>
</template> </template>
@@ -20,9 +27,11 @@ import HostActionLogModal from "./HostActionLogModal.vue";
import { import {
closeHostRemoteActionLog, closeHostRemoteActionLog,
hostRemoteActionLogs, hostRemoteActionLogs,
syncHostRemoteActionJobFinished,
toggleHostRemoteActionLog,
} from "../composables/useHostRemoteAction"; } from "../composables/useHostRemoteAction";
const openLogs = computed(() => hostRemoteActionLogs.filter((e) => e.open)); const hasOpenLogs = computed(() => hostRemoteActionLogs.some((e) => e.open));
</script> </script>
<style scoped> <style scoped>
@@ -16,11 +16,16 @@ export type HostRemoteActionLogEntry = {
id: string; id: string;
hostId: number; hostId: number;
open: boolean; open: boolean;
logVisible: boolean;
loading: boolean; loading: boolean;
ok: boolean | null; ok: boolean | null;
title: string; title: string;
message: string; message: string;
output: string; output: string;
/** Текст в окне лога, пока output с сервера ещё пустой */
logPlaceholder: string;
/** ISO-время открытия окна — отсекаем stale success от прошлого job */
sessionStartedAt: string;
}; };
export const hostRemoteActionLogs = reactive<HostRemoteActionLogEntry[]>([]); export const hostRemoteActionLogs = reactive<HostRemoteActionLogEntry[]>([]);
@@ -81,12 +86,22 @@ function scheduleSuccessAutoClose(entryId: string) {
} }
function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) { function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
entry.title = job.title || entry.title; if (job.title) {
entry.message = job.message || entry.message; entry.title = job.title;
}
if (job.message) {
entry.message = job.message;
}
const nextOutput = (job.output || job.stdout || "").trim();
if (nextOutput) {
entry.output = job.output || job.stdout || ""; entry.output = job.output || job.stdout || "";
}
} }
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) { function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
if (!entry.loading && entry.ok != null) {
return;
}
entry.loading = false; entry.loading = false;
entry.ok = job.ok ?? job.status === "success"; entry.ok = job.ok ?? job.status === "success";
applyJobToEntry(entry, job); applyJobToEntry(entry, job);
@@ -96,6 +111,16 @@ function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobSt
} }
} }
export function syncHostRemoteActionJobFinished(
entryId: string,
job: HostRemoteActionJobStatus,
): void {
const entry = findLogById(entryId);
if (!entry) return;
finishEntry(entry, job);
void patchHostFromJob(entry.hostId, job);
}
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> { async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
for (;;) { for (;;) {
const job = await fetchHostRemoteJob(hostId); const job = await fetchHostRemoteJob(hostId);
@@ -166,6 +191,7 @@ async function executeRemoteAction(
export async function pollHostRemoteAction( export async function pollHostRemoteAction(
hostId: number, hostId: number,
title: string, title: string,
logPlaceholder?: string,
): Promise<HostRemoteActionJobStatus | null> { ): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId); const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) { if (existingPromise) {
@@ -180,11 +206,14 @@ export async function pollHostRemoteAction(
id: newLogId(), id: newLogId(),
hostId, hostId,
open: true, open: true,
logVisible: false,
loading: true, loading: true,
ok: null, ok: null,
title, title,
message: "Выполняется на удалённом хосте…", message: "Выполняется на удалённом хосте…",
output: "", output: "",
logPlaceholder: logPlaceholder || "Ожидание лога с хоста…",
sessionStartedAt: new Date().toISOString(),
}; };
hostRemoteActionLogs.push(entry); hostRemoteActionLogs.push(entry);
@@ -215,6 +244,7 @@ export async function runHostRemoteAction(
hostId: number, hostId: number,
title: string, title: string,
kind: HostRemoteActionKind, kind: HostRemoteActionKind,
logPlaceholder?: string,
): Promise<HostRemoteActionJobStatus | null> { ): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId); const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) { if (existingPromise) {
@@ -229,11 +259,14 @@ export async function runHostRemoteAction(
id: newLogId(), id: newLogId(),
hostId, hostId,
open: true, open: true,
logVisible: false,
loading: true, loading: true,
ok: null, ok: null,
title, title,
message: "Запуск на сервере SAC…", message: "Запуск на сервере SAC…",
output: "", output: "",
logPlaceholder: logPlaceholder || "Ожидание лога с хоста…",
sessionStartedAt: new Date().toISOString(),
}; };
hostRemoteActionLogs.push(entry); hostRemoteActionLogs.push(entry);
@@ -246,6 +279,13 @@ export async function runHostRemoteAction(
} }
} }
export function toggleHostRemoteActionLog(entryId: string) {
const entry = findLogById(entryId);
if (!entry) return;
entry.logVisible = !entry.logVisible;
entry.open = true;
}
export function closeHostRemoteActionLog(entryId: string) { export function closeHostRemoteActionLog(entryId: string) {
clearAutoCloseTimer(entryId); clearAutoCloseTimer(entryId);
const entry = findLogById(entryId); const entry = findLogById(entryId);
+34
View File
@@ -58,3 +58,37 @@ export function remoteActionTitleForHost(host: HostSummary): string {
} }
return `Обновление ssh-monitor (SSH): ${label}`; return `Обновление ssh-monitor (SSH): ${label}`;
} }
const LOG_PLACEHOLDER_SSH_UPDATE =
"Ожидание лога с хоста…\n(обновление /var/log/update_script.log)";
const LOG_PLACEHOLDER_WINRM =
"Ожидание вывода с хоста…\n(WinRM: Deploy-LoginMonitor.ps1)";
const LOG_PLACEHOLDER_GENERIC = "Ожидание лога с хоста…";
export function remoteActionLogPlaceholder(
host: Pick<HostSummary, "os_family" | "product"> | null | undefined,
kind: HostRemoteActionKind | null,
): string {
if (kind === "ssh-update") {
return LOG_PLACEHOLDER_SSH_UPDATE;
}
if (host && isWindowsAgentHost(host)) {
return LOG_PLACEHOLDER_WINRM;
}
if (kind === "fallback") {
return LOG_PLACEHOLDER_SSH_UPDATE;
}
return LOG_PLACEHOLDER_GENERIC;
}
export function remoteActionLogPlaceholderFromTitle(title: string): string {
if (/winrm/i.test(title)) {
return LOG_PLACEHOLDER_WINRM;
}
if (/ssh-monitor|\/var\/log\/update_script/i.test(title)) {
return LOG_PLACEHOLDER_SSH_UPDATE;
}
return LOG_PLACEHOLDER_GENERIC;
}
+17
View File
@@ -0,0 +1,17 @@
/** Human-readable session_duration_sec for event tables. */
export function formatSessionDuration(seconds: number | null | undefined): string {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) {
return "—";
}
const total = Math.floor(seconds);
const days = Math.floor(total / 86_400);
const hours = Math.floor((total % 86_400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const clock = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
if (days > 0) {
return `${days}д ${clock}`;
}
return clock;
}
+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.5.0"; export const APP_VERSION = "0.5.17";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+5
View File
@@ -238,6 +238,8 @@
<th>Пользователь</th> <th>Пользователь</th>
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
<th>Версия агента</th> <th>Версия агента</th>
<th>Severity</th> <th>Severity</th>
@@ -276,6 +278,8 @@
<td>{{ e.actor_user || "—" }}</td> <td>{{ e.actor_user || "—" }}</td>
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
<td>{{ e.product_version || "—" }}</td> <td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td> <td :class="'sev-' + e.severity">{{ e.severity }}</td>
@@ -354,6 +358,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 { formatSessionDuration } from "../utils/sessionDuration";
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions"; import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta(); const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
+3
View File
@@ -34,6 +34,7 @@
<th>Хост</th> <th>Хост</th>
<th>Имя сервера</th> <th>Имя сервера</th>
<th>Пользователь</th> <th>Пользователь</th>
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
<th>Версия агента</th> <th>Версия агента</th>
<th>Severity</th> <th>Severity</th>
<th>Type</th> <th>Type</th>
@@ -50,6 +51,7 @@
<td>{{ e.hostname }}</td> <td>{{ e.hostname }}</td>
<td>{{ formatServerName(e.display_name) }}</td> <td>{{ formatServerName(e.display_name) }}</td>
<td>{{ e.actor_user || "—" }}</td> <td>{{ e.actor_user || "—" }}</td>
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
<td>{{ e.product_version || "—" }}</td> <td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td> <td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td><code>{{ e.type }}</code></td> <td><code>{{ e.type }}</code></td>
@@ -120,6 +122,7 @@ import {
type EventsListState, type EventsListState,
} from "../utils/eventsListQuery"; } from "../utils/eventsListQuery";
import { formatServerName } from "../utils/hostDisplay"; import { formatServerName } from "../utils/hostDisplay";
import { formatSessionDuration } from "../utils/sessionDuration";
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions"; import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const route = useRoute(); const route = useRoute();
+184 -1
View File
@@ -69,6 +69,52 @@
<dd>{{ host.event_count ?? 0 }}</dd> <dd>{{ host.event_count ?? 0 }}</dd>
</div> </div>
</dl> </dl>
<section v-if="showAgentConfig" class="host-mgmt-access">
<h3>{{ isWindowsHost ? "WinRM / доступ к хосту" : "SSH / доступ к хосту" }}</h3>
<p class="muted">
Переопределение для этого ПК. Если пусто используются глобальные учётные данные из
<RouterLink to="/settings">Настройки</RouterLink>
({{ isWindowsHost ? "Windows admin" : "Linux admin" }}).
Сейчас effective:
<strong>{{ accessEffectiveLabel }}</strong>
</p>
<form class="agent-config-form" @submit.prevent="saveHostAccess">
<label class="config-field">
Логин
<input
v-model="accessForm.user"
type="text"
:placeholder="isWindowsHost ? 'COMPUTER\\user или .\\Administrator' : 'root'"
autocomplete="off"
/>
</label>
<label class="config-field">
Пароль
<input
v-model="accessForm.password"
type="password"
:placeholder="accessForm.passwordSet ? '•••• (оставьте пустым, чтобы не менять)' : ''"
autocomplete="new-password"
/>
</label>
<div class="host-actions-row">
<button type="submit" :disabled="savingAccess">
{{ savingAccess ? "Сохранение…" : "Сохранить доступ" }}
</button>
<button
type="button"
class="secondary"
:disabled="savingAccess || !accessForm.hasOverride"
@click="clearHostAccess"
>
Сбросить (глобальные)
</button>
</div>
<p v-if="accessMessage" :class="accessOk ? 'success' : 'error'">{{ accessMessage }}</p>
</form>
</section>
<div v-if="isWindowsHost" class="host-actions"> <div v-if="isWindowsHost" class="host-actions">
<button <button
type="button" type="button"
@@ -296,6 +342,8 @@ import {
import { import {
apiFetch, apiFetch,
fetchHost, fetchHost,
fetchHostAccess,
updateHostAccess,
fetchRecentEvents, fetchRecentEvents,
testHostWinRm, testHostWinRm,
testHostSsh, testHostSsh,
@@ -303,6 +351,7 @@ import {
patchHostAgentConfig, patchHostAgentConfig,
type EventListResponse, type EventListResponse,
type HostDetail, type HostDetail,
type HostMgmtAccess,
} from "../api"; } from "../api";
import { emitHostPatch } from "../utils/hostPatchBus"; import { emitHostPatch } from "../utils/hostPatchBus";
import HostSessionsPanel from "../components/HostSessionsPanel.vue"; import HostSessionsPanel from "../components/HostSessionsPanel.vue";
@@ -315,6 +364,7 @@ import {
import { import {
isLinuxAgentHost, isLinuxAgentHost,
remoteActionKindForHost, remoteActionKindForHost,
remoteActionLogPlaceholder,
remoteActionTitleForHost, remoteActionTitleForHost,
type AgentGitVersions, type AgentGitVersions,
} from "../utils/hostAgentUpgrade"; } from "../utils/hostAgentUpgrade";
@@ -353,6 +403,32 @@ const configForm = reactive({
serverDisplayName: "", serverDisplayName: "",
getInventory: true, getInventory: true,
}); });
const savingAccess = ref(false);
const accessMessage = ref("");
const accessOk = ref(false);
const accessForm = reactive({
user: "",
password: "",
passwordSet: false,
hasOverride: false,
effectiveSource: "",
effectiveUser: "",
effectiveConfigured: false,
});
const accessEffectiveLabel = computed(() => {
if (!accessForm.effectiveConfigured) {
return "не настроено";
}
const src =
accessForm.effectiveSource === "host"
? "override хоста"
: accessForm.effectiveSource === "db"
? "глобальные (Settings)"
: accessForm.effectiveSource === "env"
? "env"
: accessForm.effectiveSource;
return `${accessForm.effectiveUser || "—"} · ${src}`;
});
let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null; let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null; let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null;
let sshProbeSeq = 0; let sshProbeSeq = 0;
@@ -638,7 +714,7 @@ async function runRequestAgentUpdate() {
async function runAgentFallback() { async function runAgentFallback() {
agentControlMessage.value = ""; agentControlMessage.value = "";
const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)"; const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)";
const job = await runHostRemoteAction(hostId.value, title, "fallback"); const job = await runHostRemoteAction(hostId.value, title, "fallback", remoteActionLogPlaceholder(host.value, "fallback"));
if (job) { if (job) {
agentControlOk.value = job.ok ?? false; agentControlOk.value = job.ok ?? false;
if (job.ok) { if (job.ok) {
@@ -677,6 +753,67 @@ async function saveAgentConfig() {
} }
} }
async function applyAccessView(view: HostMgmtAccess) {
accessForm.user = view.user || "";
accessForm.password = "";
accessForm.passwordSet = view.password_set;
accessForm.hasOverride = view.has_override;
accessForm.effectiveSource = view.effective_source;
accessForm.effectiveUser = view.effective_user || "";
accessForm.effectiveConfigured = view.effective_configured;
}
async function loadHostAccess() {
accessMessage.value = "";
try {
const view = await fetchHostAccess(hostId.value);
applyAccessView(view);
} catch (e) {
accessOk.value = false;
accessMessage.value = e instanceof Error ? e.message : "Не удалось загрузить доступ хоста";
}
}
async function saveHostAccess() {
savingAccess.value = true;
accessMessage.value = "";
try {
const payload: { user?: string | null; password?: string | null } = {
user: accessForm.user,
};
if (accessForm.password.trim()) {
payload.password = accessForm.password;
}
const view = await updateHostAccess(hostId.value, payload);
applyAccessView(view);
accessOk.value = true;
accessMessage.value = view.has_override
? "Сохранено (override хоста)"
: "Сохранено (логин очищен — глобальные настройки)";
} catch (e) {
accessOk.value = false;
accessMessage.value = e instanceof Error ? e.message : "Ошибка сохранения доступа";
} finally {
savingAccess.value = false;
}
}
async function clearHostAccess() {
savingAccess.value = true;
accessMessage.value = "";
try {
const view = await updateHostAccess(hostId.value, { clear: true });
applyAccessView(view);
accessOk.value = true;
accessMessage.value = "Override сброшен — используются глобальные настройки";
} catch (e) {
accessOk.value = false;
accessMessage.value = e instanceof Error ? e.message : "Ошибка сброса доступа";
} finally {
savingAccess.value = false;
}
}
async function loadHost() { async function loadHost() {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
@@ -691,6 +828,7 @@ async function loadHost() {
try { try {
const detail = await fetchHost(hostId.value); const detail = await fetchHost(hostId.value);
applyHostDetail(detail); applyHostDetail(detail);
void loadHostAccess();
if (isLinuxHostDetail(detail)) { if (isLinuxHostDetail(detail)) {
void probeSshOnOpen(); void probeSshOnOpen();
} }
@@ -706,6 +844,7 @@ async function runAgentUpdate() {
hostId.value, hostId.value,
"Обновление ssh-monitor (SSH)", "Обновление ssh-monitor (SSH)",
"ssh-update", "ssh-update",
remoteActionLogPlaceholder(host.value, "ssh-update"),
); );
if (job?.ok) { if (job?.ok) {
const detail = await fetchHost(hostId.value); const detail = await fetchHost(hostId.value);
@@ -728,6 +867,7 @@ async function runAgentUpgradeFromCell() {
hostId.value, hostId.value,
remoteActionTitleForHost(host.value), remoteActionTitleForHost(host.value),
kind, kind,
remoteActionLogPlaceholder(host.value, kind),
); );
if (job?.ok) { if (job?.ok) {
const detail = await fetchHost(hostId.value); const detail = await fetchHost(hostId.value);
@@ -845,6 +985,49 @@ watch(
color: #9aa4b2; color: #9aa4b2;
} }
.host-mgmt-access {
margin: 1rem 0 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid #2a3340;
border-radius: 8px;
background: #141a22;
}
.host-mgmt-access h3 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.host-mgmt-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 0.75rem;
max-width: 28rem;
}
.host-mgmt-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
}
.host-mgmt-field input {
padding: 0.4rem 0.55rem;
border-radius: 6px;
border: 1px solid #3a4554;
background: #0f1419;
color: #e8eef5;
}
.host-mgmt-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.host-actions { .host-actions {
margin-top: 1rem; margin-top: 1rem;
display: flex; display: flex;
+48 -2
View File
@@ -135,9 +135,12 @@ import { isAgentVersionOutdated } from "../utils/agentVersion";
import { import {
isGitAgentUpgradeAvailable, isGitAgentUpgradeAvailable,
remoteActionKindForHost, remoteActionKindForHost,
remoteActionLogPlaceholder,
remoteActionLogPlaceholderFromTitle,
remoteActionTitleForHost, remoteActionTitleForHost,
} from "../utils/hostAgentUpgrade"; } from "../utils/hostAgentUpgrade";
import { import {
hostRemoteActionLogs,
isHostRemoteActionActive, isHostRemoteActionActive,
pollHostRemoteAction, pollHostRemoteAction,
runHostRemoteAction, runHostRemoteAction,
@@ -244,6 +247,37 @@ function isVersionOutdated(h: HostSummary): boolean {
return isAgentVersionOutdated(h.product_version, reference); return isAgentVersionOutdated(h.product_version, reference);
} }
function countOutdatedUpgradeableHosts(): number {
return (data.value?.items ?? []).filter(
(h) => isGitAgentUpgradeAvailable(h, data.value) && isVersionOutdated(h),
).length;
}
function countActiveRemoteUpgrades(): number {
return hostRemoteActionLogs.filter((e) => e.loading).length;
}
function confirmMassUpgradeIfNeeded(hostname: string): boolean {
const active = countActiveRemoteUpgrades();
const outdated = countOutdatedUpgradeableHosts();
if (active >= 2) {
return window.confirm(
`Сейчас обновляется ${active} хост(ов). Рекомендуется пачками по 2–3. Продолжить обновление «${hostname}»?`,
);
}
if (outdated > 3 && active >= 1) {
return window.confirm(
`Устарело агентов на странице: ${outdated}, уже идёт обновление. Продолжить «${hostname}»?`,
);
}
if (outdated > 3) {
return window.confirm(
`Устарело агентов на странице: ${outdated}. Рекомендуется обновлять пачками по 2–3, не все сразу. Продолжить «${hostname}»?`,
);
}
return true;
}
async function startAgentUpgrade(h: HostSummary) { async function startAgentUpgrade(h: HostSummary) {
if (!isGitAgentUpgradeAvailable(h, data.value)) { if (!isGitAgentUpgradeAvailable(h, data.value)) {
return; return;
@@ -252,9 +286,17 @@ async function startAgentUpgrade(h: HostSummary) {
showHostRemoteActionLog(h.id); showHostRemoteActionLog(h.id);
return; return;
} }
if (!confirmMassUpgradeIfNeeded(h.hostname)) {
return;
}
const kind = remoteActionKindForHost(h); const kind = remoteActionKindForHost(h);
if (!kind) return; if (!kind) return;
void runHostRemoteAction(h.id, remoteActionTitleForHost(h), kind); void runHostRemoteAction(
h.id,
remoteActionTitleForHost(h),
kind,
remoteActionLogPlaceholder(h, kind),
);
} }
function openHost(id: number) { function openHost(id: number) {
@@ -415,7 +457,11 @@ interface HostDeleteResponse {
async function onManualAddStarted(payload: { hostId: number; title: string }) { async function onManualAddStarted(payload: { hostId: number; title: string }) {
await loadHosts(); await loadHosts();
void pollHostRemoteAction(payload.hostId, payload.title); void pollHostRemoteAction(
payload.hostId,
payload.title,
remoteActionLogPlaceholderFromTitle(payload.title),
);
} }
async function confirmDelete(h: HostSummary) { async function confirmDelete(h: HostSummary) {
+7 -5
View File
@@ -1,4 +1,4 @@
<template> <template>
<div class="settings-page"> <div class="settings-page">
<h1>Настройки</h1> <h1>Настройки</h1>
<p class="settings-intro"> <p class="settings-intro">
@@ -126,8 +126,9 @@
<section class="card settings-card settings-policy"> <section class="card settings-card settings-policy">
<h2>Windows (qwinsta / logoff)</h2> <h2>Windows (qwinsta / logoff)</h2>
<p class="settings-hint settings-hint-top"> <p class="settings-hint settings-hint-top">
Доменный admin для удалённых команд на Windows-хостах (qwinsta, logoff через агент). Глобальный (доменный) admin для удалённых команд на Windows-хостах (qwinsta, logoff, WinRM-update).
Формат логина: <code>ДОМЕН\пользователь</code> (например <code>B26\papatramp</code>). Формат логина: <code>ДОМЕН\пользователь</code> (например <code>B26\papatramp</code>).
Для отдельного ПК (home/workgroup) задайте override в карточке <strong>Хосты доступ к хосту</strong>.
Пока запись не создана в UI используются <code>SAC_WIN_ADMIN_*</code> из <code>sac-api.env</code>. Пока запись не создана в UI используются <code>SAC_WIN_ADMIN_*</code> из <code>sac-api.env</code>.
</p> </p>
<form class="settings-form" @submit.prevent="saveWinAdminSettings"> <form class="settings-form" @submit.prevent="saveWinAdminSettings">
@@ -186,8 +187,9 @@
<section class="card settings-card settings-policy"> <section class="card settings-card settings-policy">
<h2>Linux (SSH / обновление ssh-monitor)</h2> <h2>Linux (SSH / обновление ssh-monitor)</h2>
<p class="settings-hint settings-hint-top"> <p class="settings-hint settings-hint-top">
SSH-учётка для удалённого запуска <code>/opt/scripts/update_ssh_monitor.sh</code> с карточки Linux-хоста. Глобальная SSH-учётка для удалённого запуска <code>/opt/scripts/update_ssh_monitor.sh</code> с карточки Linux-хоста.
Рекомендуется <code>root</code> или пользователь с <code>sudo NOPASSWD</code>. Рекомендуется <code>root</code> или пользователь с <code>sudo NOPASSWD</code>.
Для отдельного сервера задайте override в карточке <strong>Хосты доступ к хосту</strong>.
Пока запись не создана в UI используются <code>SAC_LINUX_ADMIN_*</code> из <code>sac-api.env</code>. Пока запись не создана в UI используются <code>SAC_LINUX_ADMIN_*</code> из <code>sac-api.env</code>.
</p> </p>
<form class="settings-form" @submit.prevent="saveLinuxAdminSettings"> <form class="settings-form" @submit.prevent="saveLinuxAdminSettings">
@@ -251,7 +253,7 @@
<input <input
v-model="agentUpdateForm.rdp_git_repo_url" v-model="agentUpdateForm.rdp_git_repo_url"
type="text" type="text"
placeholder="https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git" placeholder="https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
/> />
</label> </label>
<label class="settings-field"> <label class="settings-field">
@@ -259,7 +261,7 @@
<input <input
v-model="agentUpdateForm.ssh_git_repo_url" v-model="agentUpdateForm.ssh_git_repo_url"
type="text" type="text"
placeholder="https://git.kalinamall.ru/PapaTramp/ssh-monitor.git" placeholder="https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
/> />
</label> </label>
<label class="settings-field"> <label class="settings-field">
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.kalinamall.ru/PapaTramp/security-alert-center/schemas/event-v1.json", "$id": "https://git.papatramp.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
"title": "Security Alert Center Event v1", "title": "Security Alert Center Event v1",
"description": "Каноническое событие от ssh-monitor или RDP-login-monitor", "description": "Каноническое событие от ssh-monitor или RDP-login-monitor",
"type": "object", "type": "object",
@@ -94,6 +94,7 @@
"session.logind.new", "session.logind.new",
"rdp.login.success", "rdp.login.success",
"rdp.login.failed", "rdp.login.failed",
"rdp.session.logoff",
"rdp.shadow.control.started", "rdp.shadow.control.started",
"rdp.shadow.control.stopped", "rdp.shadow.control.stopped",
"rdp.shadow.control.permission", "rdp.shadow.control.permission",
Executable → Regular
View File
+2 -2
View File
@@ -7,8 +7,8 @@ REMOTE_CMD = (
"set -e\n" "set -e\n"
"TMP=$(mktemp -d)\n" "TMP=$(mktemp -d)\n"
'trap "rm -rf \\"$TMP\\"" EXIT\n' 'trap "rm -rf \\"$TMP\\"" EXIT\n'
"git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git \"$TMP/rdp\"\n" "git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git \"$TMP/rdp\"\n"
"git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/ssh-monitor.git \"$TMP/ssh\"\n" "git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/ssh-monitor.git \"$TMP/ssh\"\n"
"echo RDP_version_txt:\n" "echo RDP_version_txt:\n"
"cat \"$TMP/rdp/version.txt\" 2>/dev/null || echo missing\n" "cat \"$TMP/rdp/version.txt\" 2>/dev/null || echo missing\n"
"echo RDP_script:\n" "echo RDP_script:\n"
+2 -2
View File
@@ -2,8 +2,8 @@
set -e set -e
TMP=$(mktemp -d) TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT trap 'rm -rf "$TMP"' EXIT
git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git "$TMP/rdp" git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git "$TMP/rdp"
git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/ssh-monitor.git "$TMP/ssh" git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/ssh-monitor.git "$TMP/ssh"
echo RDP_version_txt: echo RDP_version_txt:
cat "$TMP/rdp/version.txt" 2>/dev/null || echo missing cat "$TMP/rdp/version.txt" 2>/dev/null || echo missing
echo RDP_script: echo RDP_script: