Compare commits

..

34 Commits

Author SHA1 Message Date
PapaTramp 939fe122c5 fix: WinRM RDP bundle staging in %TEMP% (0.4.14)
Move zip download and extract to user TEMP to avoid ProgramData access denied on DCs. Improve WinRM ERROR line parsing and add optional SAC_AGENT_BUNDLE_BASE_URL for LAN clients.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 12:11:54 +10:00
PapaTramp c72e510fb0 fix: defer daily report push и увеличить pool БД (0.4.12)
FCM/каналы для report.daily.* после commit ingest (BackgroundTasks);
SAC_DB_POOL_SIZE=15, SAC_DB_MAX_OVERFLOW=25 для штурма 09:00.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 12:00:37 +10:00
PapaTramp 6c43b8637e fix: dedupe host_silence problems under concurrent scans (0.4.11)
Treat acknowledged as active, dedupe by hostname, add PostgreSQL advisory lock and a unique index. Migration closes existing duplicate open problems.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 09:05:28 +10:00
PapaTramp 3765d4d476 feat: RDP flap auto-disconnect and hide break button after reconnect (0.4.10)
Add optional auto logoff of stuck sessions on RDG flap and direct RDP failure. Hide the RDS break button on old flap 302 when the user later reconnects or the workstation session is closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:50:03 +10:00
PapaTramp 10ae670962 fix: prevent duplicate host_silence problems per host (0.4.8)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 09:12:10 +10:00
PapaTramp daeb7e965d fix: skip alerts for hidden event types, show them on host card (0.4.7)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:16:10 +10:00
PapaTramp 606bf53019 feat: close workstation login on RDG 303 disconnect (0.4.6)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 08:16:06 +10:00
PapaTramp fd1be5e7e4 feat: manual host add from Hosts list via WinRM/SSH (0.4.5)
Add host button probes Windows by hostname or Linux by IP, registers host in SAC and deploys agent in background.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 15:34:12 +10:00
PapaTramp 5a7909bb55 fix: clear stale remote actions on sac-api startup (0.4.4)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:44:53 +10:00
PapaTramp 86fd400f0b fix: reset stale remote agent updates after sac-api restart (0.4.3)
Clear zombie running jobs on deploy and add admin API to cancel a stuck job.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:43:31 +10:00
PapaTramp dd2ae51b43 feat: parallel agent upgrades with per-host log panels (0.4.2)
Allow multiple remote update jobs at once; each upgrade gets its own
bottom-right log dock that auto-closes 30s after success.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:22:31 +10:00
PapaTramp 719d5c5719 feat: login IP whitelist, blocks UI and fail2ban sync (0.4.1)
Admin settings for web login whitelist, blocked IP list with unblock,
and optional fail2ban ignoreip sync for SSH jail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 10:06:10 +10:00
PapaTramp bda02f67eb ui: grey out other upgrade arrows while host update runs (0.4.0)
Show inactive grey arrows on other hosts during a single remote agent update; bump SAC to 0.4.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 15:44:31 +10:00
PapaTramp 9053ef0588 feat: one-click agent upgrade from hosts list (0.3.9)
Add Omada-style git version arrow in the agent version column to trigger WinRM/SSH background updates, and soften host detail live refresh with debounced incremental event patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 15:32:09 +10:00
PapaTramp 0601aafd2d fix: hide ????????? after session terminate (0.3.8)
Persist session_terminated in event details, expose in API, and update Events/Dashboard UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:45:33 +10:00
PapaTramp 8b3e43f30d fix: event terminate-session 500 on rdp.login.success (0.3.7)
Use extract_event_actor_user instead of missing ORM field; map WinRM/SSH errors to HTTP 400.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:37:37 +10:00
PapaTramp 6330f31162 docs: sync README version lines with APP_VERSION 0.3.6
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:14:43 +10:00
PapaTramp b47cbf03c5 ui: remove duplicate SAC version from overview page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:35:45 +10:00
PapaTramp 407ef37004 fix: strict RDG 302/303 IP pairing keeps break button visible (0.3.6)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:30:16 +10:00
PapaTramp 64b942447b fix: reconcile daily report active users count and list (0.3.5)
Normalize empty-session placeholders; sync stats with report body after ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 14:41:47 +10:00
PapaTramp 8b52c5fe3c fix: show RDS break only on active or flap 302 events (0.3.4)
Hide Obryv RDS on 303 and on 302 after normal session end; keep for open sessions and RDG flap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 20:15:47 +10:00
PapaTramp fbd213f323 chore: bump npm dompurify and vite for audit fixes (0.3.3)
dompurify 3.4.11, vite 6.4.3 — npm audit reports 0 vulnerabilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 20:13:43 +10:00
PapaTramp 1b96a3b2c7 fix: RDG event label and prefix type filter (0.3.2)
Use event 302/303 instead of Win302 in RDG titles. Match event types by prefix in events list (e.g. rdg.connection).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 20:07:22 +10:00
PapaTramp 24ca87a47a feat: RDG 302/303 display path labels and RDS session break (0.3.1)
Show RDS access via RDG-Comp or Haproxy-RDG-Comp, enable qwinsta/logoff on any RDG event with internal_ip.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 19:52:22 +10:00
PapaTramp 4eb41608ea chore: release SAC 0.3.0
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:07:54 +10:00
PapaTramp 6e3f0ede4d fix(backend): loginctl JSON parse and filter ephemeral SSH sessions (0.20.34)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 16:00:02 +10:00
PapaTramp fe818e9946 feat(frontend): terminate session button on Overview recent events (0.20.33)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 15:44:49 +10:00
PapaTramp 5e26834e16 feat(frontend): git agent versions plate on Hosts list (0.20.32)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 15:17:47 +10:00
PapaTramp 54c1d96933 fix(backend): non-login SSH and strict loginctl parse, SSH retry (0.20.31)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:54:56 +10:00
PapaTramp a51a585b14 fix(frontend): restore host config/sessions row column flex
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:31:48 +10:00
PapaTramp fc66b57e78 fix(frontend): match sessions panel height to agent config until loaded (0.20.30)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:31:38 +10:00
PapaTramp ad2425b0ff fix(frontend): place host sessions panel beside agent config (0.20.29)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:22:20 +10:00
PapaTramp d848d94604 feat: host session list/terminate and event session actions (0.20.28)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:13:42 +10:00
PapaTramp ca0255e13b fix(frontend): live refresh Events list via SSE (0.20.27)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 09:11:19 +10:00
81 changed files with 6511 additions and 413 deletions
+2 -2
View File
@@ -13,7 +13,7 @@
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) | | **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
**Версия:** `0.20.18` · **Деплой:** `sudo /opt/sac-deploy.sh` **Версия:** `0.4.11` · **Деплой:** `sudo /opt/sac-deploy.sh`
## Возможности ## Возможности
@@ -21,7 +21,7 @@
- **Problems** — автокорреляция (в т.ч. RDG 302→303 flap за 110 с) - **Problems** — автокорреляция (в т.ч. RDG 302→303 flap за 110 с)
- **Оповещения** — Telegram, email, webhook, FCM (Seaca); severity + cooldown - **Оповещения** — Telegram, email, webhook, FCM (Seaca); severity + cooldown
- **UI** — события, хосты, problems, отчёты, обзор (SSE live), настройки (admin) - **UI** — события, хосты, problems, отчёты, обзор (SSE live), настройки (admin)
- **RDG flap** — бейдж на событиях 302/303, **qwinsta/logoff** по WinRM на клиентский ПК (нужен domain admin в настройках) - **RDG flap** — бейдж на событиях 302/303, **qwinsta/logoff** по WinRM на клиентский ПК (нужен domain admin в настройках); опционально — автоотключение зависшей сессии при flap
- **Хосты** — статус агента, inventory, обновление ssh-monitor/RDP (SSH/WinRM), тест Git/SSH/WinRM; WinRM RDP: SAC тянет пакет с git, клиент скачивает zip с SAC, локальный `Deploy-LoginMonitor.ps1` (git на ПК не нужен) - **Хосты** — статус агента, inventory, обновление ssh-monitor/RDP (SSH/WinRM), тест Git/SSH/WinRM; WinRM RDP: SAC тянет пакет с git, клиент скачивает zip с SAC, локальный `Deploy-LoginMonitor.ps1` (git на ПК не нужен)
- **Мобильные** — enrollment, устройства, push; Seaca: ack/resolve, qwinsta/logoff через API SAC - **Мобильные** — enrollment, устройства, push; Seaca: ack/resolve, qwinsta/logoff через API SAC
- **Роли** — `admin` / `monitor`; JWT, rate limit входа - **Роли** — `admin` / `monitor`; JWT, rate limit входа
+2 -2
View File
@@ -13,7 +13,7 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre
| **security-alert-center** | SAC server (Ubuntu 24.04) | | **security-alert-center** | SAC server (Ubuntu 24.04) |
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client | | [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client |
**Version:** `0.20.18` · **Deploy:** `sudo /opt/sac-deploy.sh` **Version:** `0.4.11` · **Deploy:** `sudo /opt/sac-deploy.sh`
## Features ## Features
@@ -21,7 +21,7 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre
- **Problems** — auto-correlation (incl. RDG 302→303 flap within 110 s) - **Problems** — auto-correlation (incl. RDG 302→303 flap within 110 s)
- **Notifications** — Telegram, email, webhook, FCM (Seaca) - **Notifications** — Telegram, email, webhook, FCM (Seaca)
- **Web UI** — events, hosts, problems, reports, dashboard (SSE), settings (admin) - **Web UI** — events, hosts, problems, reports, dashboard (SSE), settings (admin)
- **RDG flap** — badge on 302/303 events, **qwinsta/logoff** via WinRM to client PC (domain admin required) - **RDG flap** — badge on 302/303 events, **qwinsta/logoff** via WinRM to client PC (domain admin required); optional auto-disconnect of stuck sessions
- **Hosts** — agent status, inventory, agent updates (SSH/WinRM); WinRM RDP: SAC fetches from git, client downloads zip from SAC, runs local `Deploy-LoginMonitor.ps1` (no git on PC) - **Hosts** — agent status, inventory, agent updates (SSH/WinRM); WinRM RDP: SAC fetches from git, client downloads zip from SAC, runs local `Deploy-LoginMonitor.ps1` (no git on PC)
- **Mobile** — enrollment, devices, push; Seaca: ack/resolve, qwinsta/logoff via SAC API - **Mobile** — enrollment, devices, push; Seaca: ack/resolve, qwinsta/logoff via SAC API
- **Roles** — `admin` / `monitor` - **Roles** — `admin` / `monitor`
@@ -0,0 +1,28 @@
"""login security whitelist in ui_settings
Revision ID: 024_login_security
Revises: 023_event_type_visibility
Create Date: 2026-06-25
"""
from alembic import op
import sqlalchemy as sa
revision = "024_login_security"
down_revision = "023_event_type_visibility"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("ui_settings", sa.Column("login_ip_whitelist", sa.Text(), nullable=True))
op.add_column(
"ui_settings",
sa.Column("login_sync_fail2ban", sa.Boolean(), server_default="false", nullable=False),
)
def downgrade() -> None:
op.drop_column("ui_settings", "login_sync_fail2ban")
op.drop_column("ui_settings", "login_ip_whitelist")
@@ -0,0 +1,26 @@
"""auto RDP flap disconnect setting in ui_settings
Revision ID: 025_auto_rdp_flap_disconnect
Revises: 024_login_security
Create Date: 2026-07-02
"""
from alembic import op
import sqlalchemy as sa
revision = "025_auto_rdp_flap_disconnect"
down_revision = "024_login_security"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ui_settings",
sa.Column("auto_rdp_flap_disconnect", sa.Boolean(), server_default="false", nullable=False),
)
def downgrade() -> None:
op.drop_column("ui_settings", "auto_rdp_flap_disconnect")
@@ -0,0 +1,74 @@
"""dedupe host_silence problems + unique active index
Revision ID: 026_host_silence_dedupe
Revises: 025_auto_rdp_flap_disconnect
Create Date: 2026-07-03
"""
from alembic import op
revision = "026_host_silence_dedupe"
down_revision = "025_auto_rdp_flap_disconnect"
branch_labels = None
depends_on = None
def _dedupe_host_silence_problems() -> None:
op.execute(
"""
WITH ranked AS (
SELECT
p.id,
ROW_NUMBER() OVER (
PARTITION BY p.host_id
ORDER BY p.last_seen_at DESC, p.id DESC
) AS rn
FROM problems p
WHERE p.rule_id = 'rule:host_silence'
AND p.status IN ('open', 'acknowledged')
)
UPDATE problems
SET status = 'resolved',
resolved_by = 'auto',
updated_at = NOW()
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)
"""
)
op.execute(
"""
WITH ranked AS (
SELECT
p.id,
ROW_NUMBER() OVER (
PARTITION BY lower(h.hostname)
ORDER BY p.last_seen_at DESC, p.id DESC
) AS rn
FROM problems p
JOIN hosts h ON h.id = p.host_id
WHERE p.rule_id = 'rule:host_silence'
AND p.status IN ('open', 'acknowledged')
)
UPDATE problems
SET status = 'resolved',
resolved_by = 'auto',
updated_at = NOW()
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)
"""
)
def upgrade() -> None:
_dedupe_host_silence_problems()
op.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_problems_host_silence_active
ON problems (host_id)
WHERE rule_id = 'rule:host_silence'
AND status IN ('open', 'acknowledged')
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_problems_host_silence_active")
+100 -7
View File
@@ -2,7 +2,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
@@ -20,10 +20,25 @@ from app.services.agent_commands import (
get_command_by_uuid, get_command_by_uuid,
) )
from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
from app.services.host_sessions import (
event_session_id,
event_session_terminated,
event_supports_session_terminate,
mark_event_session_terminated,
terminate_session_for_event,
)
from app.services.linux_admin_settings import get_effective_linux_admin_config
from app.services.ssh_connect import (
HostNotLinuxError as SshHostNotLinuxError,
HostTargetMissingError as SshHostTargetMissingError,
)
from app.services.win_admin_settings import get_effective_win_admin_config
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
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
from app.services.problems import maybe_create_problem from app.services.problems import maybe_create_problem
from app.services.rdp_flap_auto_disconnect import maybe_auto_disconnect_stuck_rdp_session
from app.services.schema_validate import validate_event_payload from app.services.schema_validate import validate_event_payload
from app.services.notify_dispatch import ( from app.services.notify_dispatch import (
AUTH_LOGIN_SUCCESS_TYPES, AUTH_LOGIN_SUCCESS_TYPES,
@@ -36,6 +51,7 @@ from app.services.notify_dispatch import (
notify_lifecycle, notify_lifecycle,
notify_problem, notify_problem,
notify_rdg_connection, notify_rdg_connection,
schedule_notify_daily_report,
) )
router = APIRouter(prefix="/events", tags=["events"]) router = APIRouter(prefix="/events", tags=["events"])
@@ -64,6 +80,7 @@ def _ingest_response(event: Event, *, created: bool) -> IngestResponse:
@router.post("", response_model=IngestResponse) @router.post("", response_model=IngestResponse)
def post_event( def post_event(
background_tasks: BackgroundTasks,
payload: dict[str, Any] = Body(...), payload: dict[str, Any] = Body(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_api_key: str = Depends(get_api_key_auth), _api_key: str = Depends(get_api_key_auth),
@@ -81,10 +98,12 @@ def post_event(
event, created = ingest_event(db, payload) event, created = ingest_event(db, payload)
problem = None problem = None
problem_created = False problem_created = False
deferred_daily_report_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)
if event.type in DAILY_REPORT_EVENT_TYPES: if event.type in DAILY_REPORT_EVENT_TYPES:
notify_daily_report(event, db=db) deferred_daily_report_id = event.id
elif event.type == LIFECYCLE_EVENT_TYPE: elif event.type == LIFECYCLE_EVENT_TYPE:
notify_lifecycle(event, db=db) notify_lifecycle(event, db=db)
elif event.type in AUTH_LOGIN_SUCCESS_TYPES: elif event.type in AUTH_LOGIN_SUCCESS_TYPES:
@@ -100,6 +119,9 @@ def post_event(
logger.info("ingest duplicate event_id=%s", event.event_id) logger.info("ingest duplicate event_id=%s", event.event_id)
db.commit() db.commit()
if deferred_daily_report_id is not None:
background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id)
body = _ingest_response(event, created=created) body = _ingest_response(event, created=created)
if problem is not None: if problem is not None:
body.problem_id = problem.id body.problem_id = problem.id
@@ -129,14 +151,22 @@ def list_events(
type: str | None = None, type: str | None = None,
host_id: int | None = None, host_id: int | None = None,
hostname: str | None = None, hostname: str | None = None,
include_hidden: bool = False,
from_time: str | None = Query(None, alias="from"), from_time: str | None = Query(None, alias="from"),
to_time: str | None = Query(None, alias="to"), to_time: str | None = Query(None, alias="to"),
q: str | None = None, q: str | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
) -> EventListResponse: ) -> EventListResponse:
if include_hidden and host_id is None:
raise HTTPException(
status_code=400,
detail="include_hidden requires host_id",
)
hidden = get_hidden_event_types(db) hidden = get_hidden_event_types(db)
type_filter = visibility_type_filter(hidden) type_filter = visibility_type_filter(hidden)
if include_hidden and host_id is not None:
type_filter = None
stmt = select(Event).join(Host).options(joinedload(Event.host)) stmt = select(Event).join(Host).options(joinedload(Event.host))
count_stmt = select(func.count()).select_from(Event).join(Host) count_stmt = select(func.count()).select_from(Event).join(Host)
if type_filter is not None: if type_filter is not None:
@@ -147,8 +177,10 @@ def list_events(
stmt = stmt.where(Event.severity == severity) stmt = stmt.where(Event.severity == severity)
count_stmt = count_stmt.where(Event.severity == severity) count_stmt = count_stmt.where(Event.severity == severity)
if type: if type:
stmt = stmt.where(Event.type == type) normalized = type.strip()
count_stmt = count_stmt.where(Event.type == type) if normalized:
stmt = stmt.where(Event.type.ilike(f"{normalized}%"))
count_stmt = count_stmt.where(Event.type.ilike(f"{normalized}%"))
if host_id is not None: if host_id is not None:
stmt = stmt.where(Event.host_id == host_id) stmt = stmt.where(Event.host_id == host_id)
count_stmt = count_stmt.where(Event.host_id == host_id) count_stmt = count_stmt.where(Event.host_id == host_id)
@@ -214,6 +246,70 @@ class LogoffActionBody(BaseModel):
session_id: int session_id: int
class EventSessionTerminateBody(BaseModel):
session_id: str | None = None
class EventSessionTerminateResponse(BaseModel):
ok: bool
message: str
target: str | None = None
stdout: str | None = None
stderr: str | None = None
@router.post("/{event_db_id}/actions/terminate-session", response_model=EventSessionTerminateResponse)
def post_event_terminate_session(
event_db_id: int,
body: EventSessionTerminateBody | None = Body(default=None),
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> EventSessionTerminateResponse:
event = db.scalar(
select(Event).options(joinedload(Event.host)).where(Event.id == event_db_id)
)
if event is None:
raise HTTPException(status_code=404, detail="Event not found")
if not event_supports_session_terminate(event):
raise HTTPException(status_code=400, detail="Event type does not support session terminate")
if event_session_terminated(event, db=db):
raise HTTPException(status_code=409, detail="Session already terminated for this event")
linux_cfg = get_effective_linux_admin_config(db)
win_cfg = get_effective_win_admin_config(db)
sid = (body.session_id if body else None) or event_session_id(event)
try:
result = terminate_session_for_event(
event,
linux_cfg=linux_cfg,
win_cfg=win_cfg,
session_id=sid,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HostNotWindowsError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostNotLinuxError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
response = EventSessionTerminateResponse(
ok=result.ok,
message=result.message,
target=getattr(result, "target", None),
stdout=getattr(result, "stdout", None) or None,
stderr=getattr(result, "stderr", None) or None,
)
if result.ok:
mark_event_session_terminated(event, by_username=user.username)
db.commit()
return response
@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse) @router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse)
def post_event_qwinsta( def post_event_qwinsta(
event_db_id: int, event_db_id: int,
@@ -267,14 +363,11 @@ def get_event(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
) -> EventDetail: ) -> EventDetail:
hidden = get_hidden_event_types(db)
event = db.scalar( event = db.scalar(
select(Event).where(Event.id == event_db_id).options(joinedload(Event.host)) select(Event).where(Event.id == event_db_id).options(joinedload(Event.host))
) )
if event is None: if event is None:
raise HTTPException(status_code=404, detail="Event not found") raise HTTPException(status_code=404, detail="Event not found")
if event.type in hidden:
raise HTTPException(status_code=404, detail="Event not found")
base = event_to_summary(event, db) base = event_to_summary(event, db)
return EventDetail( return EventDetail(
**base.model_dump(), **base.model_dump(),
+231 -3
View File
@@ -1,5 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status from typing import Literal
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -22,13 +24,26 @@ from app.services.agent_update import (
) )
from app.services.host_remote_actions import ( from app.services.host_remote_actions import (
RemoteActionAlreadyRunningError, RemoteActionAlreadyRunningError,
cancel_host_remote_action,
clear_stale_running_remote_actions,
get_remote_action_status, get_remote_action_status,
run_agent_fallback_action, run_agent_fallback_action,
run_ssh_monitor_update_action, run_ssh_monitor_update_action,
start_host_remote_action, start_host_remote_action,
) )
from app.services.agent_update_settings import get_effective_agent_update_config from app.services.agent_update_settings import (
PRODUCT_RDP,
PRODUCT_SSH,
get_effective_agent_update_config,
)
from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config
from app.services.host_sessions import (
HostSessionRow,
list_linux_sessions,
list_windows_sessions,
terminate_linux_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.linux_admin_settings import get_effective_linux_admin_config
from app.services.win_admin_settings import get_effective_win_admin_config from app.services.win_admin_settings import get_effective_win_admin_config
@@ -158,9 +173,16 @@ def list_hosts(
page_size=page_size, page_size=page_size,
latest_agent_versions=latest_map, latest_agent_versions=latest_map,
reference_agent_versions=reference_map, reference_agent_versions=reference_map,
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
) )
class HostManualAddBody(BaseModel):
platform: Literal["windows", "linux"]
target: str = Field(..., min_length=1, max_length=253)
def _host_detail_from_model( def _host_detail_from_model(
host: Host, host: Host,
*, *,
@@ -283,6 +305,46 @@ class HostRemoteActionStartResponse(BaseModel):
message: str message: str
@router.post(
"/manual-add",
response_model=HostRemoteActionStartResponse,
status_code=status.HTTP_202_ACCEPTED,
)
def post_host_manual_add(
body: HostManualAddBody,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostRemoteActionStartResponse:
from app.services.host_manual_add import ManualHostAddError, prepare_manual_host_add
try:
host = prepare_manual_host_add(db, platform=body.platform, target=body.target)
except ManualHostAddError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if body.platform == "windows":
title = f"Ручное добавление Windows: {host.hostname}"
else:
title = f"Ручное добавление Linux: {host.hostname}"
try:
start_host_remote_action(
db,
host,
title=title,
runner=run_agent_fallback_action,
)
except RemoteActionAlreadyRunningError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return HostRemoteActionStartResponse(
status="running",
host_id=host.id,
title=title,
message="Проверка пройдена, установка агента запущена на сервере SAC",
)
class HostRemoteActionJobResponse(BaseModel): class HostRemoteActionJobResponse(BaseModel):
active: bool active: bool
host_id: int host_id: int
@@ -302,6 +364,48 @@ class HostRemoteActionJobResponse(BaseModel):
finished_at: str | None = None finished_at: str | None = None
class HostSessionItem(BaseModel):
session_id: str
user: str
tty: str | None = None
state: str | None = None
source_ip: str | None = None
session_name: str | None = None
class HostSessionsResponse(BaseModel):
ok: bool
message: str
target: str | None = None
sessions: list[HostSessionItem]
class HostSessionTerminateBody(BaseModel):
session_id: str
class HostSessionTerminateResponse(BaseModel):
ok: bool
message: str
target: str | None = None
stdout: str | None = None
stderr: str | None = None
def _session_items(rows: list[HostSessionRow]) -> list[HostSessionItem]:
return [
HostSessionItem(
session_id=r.session_id,
user=r.user,
tty=r.tty,
state=r.state,
source_ip=r.source_ip,
session_name=r.session_name,
)
for r in rows
]
def _ssh_action_response( def _ssh_action_response(
result: SshCommandResult, result: SshCommandResult,
*, *,
@@ -550,6 +654,130 @@ def get_host_remote_job(
return HostRemoteActionJobResponse(**get_remote_action_status(host)) return HostRemoteActionJobResponse(**get_remote_action_status(host))
@router.post("/{host_id}/actions/remote-job/cancel", response_model=HostRemoteActionJobResponse)
def cancel_host_remote_job(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostRemoteActionJobResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
if not cancel_host_remote_action(db, host):
raise HTTPException(status_code=409, detail="No running remote action for this host")
db.refresh(host)
return HostRemoteActionJobResponse(**get_remote_action_status(host))
@router.post("/{host_id}/actions/sessions/list", response_model=HostSessionsResponse)
def list_host_sessions(
host_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostSessionsResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
from app.services.ssh_connect import is_linux_host
from app.services.winrm_connect import is_windows_host
if is_linux_host(host):
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
try:
sessions, result = list_linux_sessions(host, cfg)
except SshHostNotLinuxError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return HostSessionsResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
sessions=_session_items(sessions),
)
if is_windows_host(host):
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
try:
sessions, result = list_windows_sessions(host, cfg)
except HostNotWindowsError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except HostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=502, detail="WinRM qwinsta failed")
return HostSessionsResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
sessions=_session_items(sessions),
)
raise HTTPException(status_code=400, detail="Host OS does not support live sessions")
@router.post("/{host_id}/actions/sessions/terminate", response_model=HostSessionTerminateResponse)
def terminate_host_session(
host_id: int,
body: HostSessionTerminateBody,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostSessionTerminateResponse:
host = db.get(Host, host_id)
if host is None:
raise HTTPException(status_code=404, detail="Host not found")
from app.services.ssh_connect import is_linux_host
from app.services.winrm_connect import is_windows_host
sid = body.session_id.strip()
if not sid:
raise HTTPException(status_code=422, detail="session_id is required")
if is_linux_host(host):
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
try:
result = terminate_linux_session(host, cfg, sid)
except SshHostNotLinuxError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except SshHostTargetMissingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return HostSessionTerminateResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
stdout=result.stdout or None,
stderr=result.stderr or None,
)
if is_windows_host(host):
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
try:
result = terminate_windows_session(host, cfg, sid)
except HostNotWindowsError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=502, detail="WinRM logoff failed")
return HostSessionTerminateResponse(
ok=result.ok,
message=result.message,
target=result.target or None,
stdout=result.stdout or None,
stderr=result.stderr or None,
)
raise HTTPException(status_code=400, detail="Host OS does not support session terminate")
@router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse) @router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
def patch_host_agent_config( def patch_host_agent_config(
host_id: int, host_id: int,
+155
View File
@@ -50,6 +50,10 @@ from app.services.win_admin_settings import (
get_effective_win_admin_config, get_effective_win_admin_config,
upsert_win_admin_settings, upsert_win_admin_settings,
) )
from app.services.rdp_flap_settings import (
get_effective_rdp_flap_settings,
upsert_rdp_flap_settings,
)
from app.services.agent_git_release import get_git_release_versions from app.services.agent_git_release import get_git_release_versions
from app.services.agent_update_settings import ( from app.services.agent_update_settings import (
get_effective_agent_update_config, get_effective_agent_update_config,
@@ -520,6 +524,45 @@ def update_win_admin_settings(
) )
class RdpFlapSettingsResponse(BaseModel):
auto_disconnect: bool
win_admin_configured: bool
source: str = Field(description="db или default")
class RdpFlapSettingsUpdate(BaseModel):
auto_disconnect: bool
@router.get("/rdp-flap", response_model=RdpFlapSettingsResponse)
def get_rdp_flap_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> RdpFlapSettingsResponse:
cfg = get_effective_rdp_flap_settings(db)
win_cfg = get_effective_win_admin_config(db)
return RdpFlapSettingsResponse(
auto_disconnect=cfg.auto_disconnect,
win_admin_configured=win_cfg.configured,
source=cfg.source,
)
@router.put("/rdp-flap", response_model=RdpFlapSettingsResponse)
def update_rdp_flap_settings(
body: RdpFlapSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> RdpFlapSettingsResponse:
cfg = upsert_rdp_flap_settings(db, auto_disconnect=body.auto_disconnect)
win_cfg = get_effective_win_admin_config(db)
return RdpFlapSettingsResponse(
auto_disconnect=cfg.auto_disconnect,
win_admin_configured=win_cfg.configured,
source=cfg.source,
)
class LinuxAdminSettingsResponse(BaseModel): class LinuxAdminSettingsResponse(BaseModel):
configured: bool configured: bool
user: str | None = None user: str | None = None
@@ -703,3 +746,115 @@ def test_agent_git_release(
git_errors=dict(git_release.errors), git_errors=dict(git_release.errors),
from_cache=git_release.from_cache, from_cache=git_release.from_cache,
) )
class LoginSecuritySettingsResponse(BaseModel):
ip_whitelist: list[str] = Field(default_factory=list)
ip_whitelist_text: str = ""
sync_fail2ban: bool = False
max_failures: int = 3
window_minutes: int = 15
fail2ban_available: bool = False
source: str = "default"
class LoginSecuritySettingsUpdate(BaseModel):
ip_whitelist_text: str = ""
sync_fail2ban: bool | None = None
class LoginBlockItem(BaseModel):
ip_address: str
scope: str
failure_count: int | None = None
usernames: list[str] = Field(default_factory=list)
blocked_until: datetime | None = None
reason: str
class LoginUnblockRequest(BaseModel):
ip_address: str
scope: str = Field(default="both", description="web | ssh | both")
class LoginUnblockResponse(BaseModel):
messages: list[str] = Field(default_factory=list)
def _login_security_response(db: Session) -> LoginSecuritySettingsResponse:
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
from app.services.fail2ban_sync import fail2ban_available
from app.services.login_security_settings import get_effective_login_security_config
cfg = get_effective_login_security_config(db)
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
text = (row.login_ip_whitelist or "") if row is not None else ""
return LoginSecuritySettingsResponse(
ip_whitelist=list(cfg.ip_whitelist),
ip_whitelist_text=text,
sync_fail2ban=cfg.sync_fail2ban,
max_failures=cfg.max_failures,
window_minutes=cfg.window_minutes,
fail2ban_available=fail2ban_available(),
source=cfg.source,
)
def _block_item(entry) -> LoginBlockItem:
return LoginBlockItem(
ip_address=entry.ip_address,
scope=entry.scope,
failure_count=entry.failure_count,
usernames=list(entry.usernames),
blocked_until=entry.blocked_until,
reason=entry.reason,
)
@router.get("/login-security", response_model=LoginSecuritySettingsResponse)
def get_login_security_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> LoginSecuritySettingsResponse:
return _login_security_response(db)
@router.put("/login-security", response_model=LoginSecuritySettingsResponse)
def update_login_security_settings(
body: LoginSecuritySettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> LoginSecuritySettingsResponse:
from app.services.login_security_settings import upsert_login_security_settings
upsert_login_security_settings(
db,
ip_whitelist_text=body.ip_whitelist_text,
sync_fail2ban=bool(body.sync_fail2ban),
)
return _login_security_response(db)
@router.get("/login-security/blocks", response_model=list[LoginBlockItem])
def list_login_security_blocks(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> list[LoginBlockItem]:
from app.services.login_security_settings import list_all_login_blocks
return [_block_item(e) for e in list_all_login_blocks(db)]
@router.post("/login-security/unblock", response_model=LoginUnblockResponse)
def unblock_login_security_ip(
body: LoginUnblockRequest,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> LoginUnblockResponse:
from app.services.login_security_settings import unblock_login_ip
scope = (body.scope or "both").strip().lower()
if scope not in ("web", "ssh", "both"):
raise HTTPException(status_code=422, detail="scope must be web, ssh, or both")
messages = unblock_login_ip(db, body.ip_address, scope=scope)
return LoginUnblockResponse(messages=messages)
+10
View File
@@ -34,7 +34,11 @@ class Settings(BaseSettings):
model_config = _settings_config() model_config = _settings_config()
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_max_overflow: int = 25
sac_public_url: str = "http://localhost:8000" sac_public_url: str = "http://localhost:8000"
# URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL.
sac_agent_bundle_base_url: str = ""
jwt_secret: str = "change-me-in-production" jwt_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24 jwt_expire_minutes: int = 60 * 24
@@ -106,6 +110,8 @@ class Settings(BaseSettings):
sac_rdg_flap_window_min_sec: int = 1 sac_rdg_flap_window_min_sec: int = 1
sac_rdg_flap_window_max_sec: int = 10 sac_rdg_flap_window_max_sec: int = 10
sac_rdg_flap_dedup_sec: int = 30 sac_rdg_flap_dedup_sec: int = 30
# Внешние IP HAProxy/RDG-прокси: если external_ip в списке — путь Haproxy-RDG-Comp, иначе RDG-Comp
sac_rdg_haproxy_external_ips: str = ""
# Windows admin for agent qwinsta/logoff (domain-wide) # Windows admin for agent qwinsta/logoff (domain-wide)
sac_win_admin_user: str = "" sac_win_admin_user: str = ""
@@ -139,6 +145,10 @@ class Settings(BaseSettings):
sac_login_max_failures: int = 3 sac_login_max_failures: int = 3
sac_login_failure_window_minutes: int = 15 sac_login_failure_window_minutes: int = 15
sac_login_alert_telegram: bool = True sac_login_alert_telegram: bool = True
sac_login_ip_whitelist: str = ""
sac_fail2ban_ssh_jail: str = "sshd"
sac_fail2ban_sync_enabled: bool = False
sac_fail2ban_ignoreip_file: str = "/etc/fail2ban/jail.d/sac-login-whitelist.local"
# Seaca mobile (FCM push) # Seaca mobile (FCM push)
sac_fcm_enabled: bool = False sac_fcm_enabled: bool = False
+5 -1
View File
@@ -6,7 +6,11 @@ from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings from app.config import get_settings
settings = get_settings() settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True) _engine_kwargs: dict = {"pool_pre_ping": True}
if settings.database_url.startswith("postgresql"):
_engine_kwargs["pool_size"] = max(1, int(settings.sac_db_pool_size))
_engine_kwargs["max_overflow"] = max(0, int(settings.sac_db_max_overflow))
engine = create_engine(settings.database_url, **_engine_kwargs)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
+13
View File
@@ -53,11 +53,24 @@ def bootstrap_users() -> None:
db.close() db.close()
def bootstrap_stale_remote_actions() -> None:
db = SessionLocal()
try:
from app.services.host_remote_actions import clear_stale_running_remote_actions
cleared = clear_stale_running_remote_actions(db)
for hostname in cleared:
logger.info("Stale remote action reset on startup: %s", hostname)
finally:
db.close()
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION) logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION)
bootstrap_api_key() bootstrap_api_key()
bootstrap_users() bootstrap_users()
bootstrap_stale_remote_actions()
stop_scan = asyncio.Event() stop_scan = asyncio.Event()
scan_task: asyncio.Task | None = None scan_task: asyncio.Task | None = None
+5
View File
@@ -33,3 +33,8 @@ class UiSettings(Base):
agent_git_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True) agent_git_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_git_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True) agent_git_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_git_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) agent_git_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
login_ip_whitelist: Mapped[str | None] = mapped_column(Text, nullable=True)
login_sync_fail2ban: Mapped[bool] = mapped_column(default=False, server_default="false", nullable=False)
auto_rdp_flap_disconnect: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", nullable=False
)
+11
View File
@@ -53,6 +53,14 @@ class HostListResponse(BaseModel):
default_factory=dict, default_factory=dict,
description="Эталон для «устарела»: max(git latest, min, max в fleet)", description="Эталон для «устарела»: max(git latest, min, max в fleet)",
) )
git_latest_rdp_version: str | None = Field(
None,
description="Последняя версия rdp-login-monitor из git (version.txt / main)",
)
git_latest_ssh_version: str | None = Field(
None,
description="Последняя версия ssh-monitor из git (version.txt / main)",
)
class EventSummary(BaseModel): class EventSummary(BaseModel):
@@ -73,6 +81,9 @@ class EventSummary(BaseModel):
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
rdg_access_path: str | None = None
rdg_qwinsta_enabled: bool = False
session_terminated: bool = False
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+68 -2
View File
@@ -16,6 +16,10 @@ SSH_BAN_TYPES = frozenset({"ssh.ip.banned"})
_SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\s*:") _SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\s*:")
_ACTIVE_USERS_HEADER_RE = re.compile(r"^👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ") _ACTIVE_USERS_HEADER_RE = re.compile(r"^👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ")
_ACTIVE_USERS_EMPTY_LINE_RE = re.compile(
r"^\(?нет данных|нет активных пользователей",
re.IGNORECASE,
)
_AGENT_VERSION_LINE_RE = re.compile(r"(?m)^Agent version\s+", re.IGNORECASE) _AGENT_VERSION_LINE_RE = re.compile(r"(?m)^Agent version\s+", re.IGNORECASE)
_NOTIFICATION_SOURCE_RE = re.compile(r"(?m)^📡\s*Оповещение:\s*") _NOTIFICATION_SOURCE_RE = re.compile(r"(?m)^📡\s*Оповещение:\s*")
_LEGACY_SAC_SOURCE_RE = re.compile( _LEGACY_SAC_SOURCE_RE = re.compile(
@@ -130,10 +134,21 @@ def collapse_blank_lines(text: str, *, max_run: int = 1) -> str:
return "\n".join(out).strip() return "\n".join(out).strip()
def is_active_users_empty_line(text: str) -> bool:
"""Строка-заглушка вместо списка сессий (агент или SAC)."""
s = text.strip()
if not s:
return True
inner = s.lstrip("👤").strip()
if _ACTIVE_USERS_EMPTY_LINE_RE.search(inner):
return True
return False
def split_active_user_tokens(entry: str) -> list[str]: def split_active_user_tokens(entry: str) -> list[str]:
"""Разбивает «👤 u1 👤 u2» на отдельные строки (как в отчёте агента).""" """Разбивает «👤 u1 👤 u2» на отдельные строки (как в отчёте агента)."""
entry = entry.strip() entry = entry.strip()
if not entry: if not entry or is_active_users_empty_line(entry):
return [] return []
if entry.count("👤") <= 1: if entry.count("👤") <= 1:
line = entry if entry.startswith("👤") else f"👤 {entry}" line = entry if entry.startswith("👤") else f"👤 {entry}"
@@ -231,21 +246,71 @@ def normalize_active_users_in_body(body: str) -> str:
break break
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"): if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
break break
if stripped.startswith("(нет данных"): if is_active_users_empty_line(stripped):
out[-1] = _fix_active_users_header_count(out[-1], 0)
out.append(cur) out.append(cur)
i += 1 i += 1
break break
user_lines.extend(split_active_user_tokens(cur)) user_lines.extend(split_active_user_tokens(cur))
i += 1 i += 1
user_lines = [ln for ln in user_lines if not is_active_users_empty_line(ln.strip())]
if user_lines: if user_lines:
out[-1] = _fix_active_users_header_count(out[-1], len(user_lines)) out[-1] = _fix_active_users_header_count(out[-1], len(user_lines))
out.extend(user_lines) out.extend(user_lines)
else:
out[-1] = _fix_active_users_header_count(out[-1], 0)
continue continue
out.append(line) out.append(line)
i += 1 i += 1
return "\n".join(out) return "\n".join(out)
def extract_active_users_from_report_body(body: str) -> list[str]:
lines = body.replace("\r\n", "\n").split("\n")
in_section = False
users: list[str] = []
for line in lines:
stripped = line.strip()
if _ACTIVE_USERS_HEADER_RE.match(stripped):
in_section = True
continue
if not in_section:
continue
if not stripped:
break
if is_active_users_empty_line(stripped):
break
if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX):
break
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
break
users.extend(split_active_user_tokens(stripped))
return normalize_active_users_list(users)
def reconcile_stats_from_report_body(
stats: dict[str, Any],
body: str,
platform: Platform,
) -> dict[str, Any]:
"""Синхронизирует stats.active_users и счётчики с текстом отчёта после нормализации."""
out = dict(stats)
users = extract_active_users_from_report_body(body)
out["active_users"] = users
count = len(users)
if platform == "ssh":
out["active_sessions"] = count
else:
out["active_sessions_rdp"] = count
names: list[str] = []
for raw in users:
name = re.sub(r"^👤\s*", "", raw.strip()).strip()
if name and not is_active_users_empty_line(name):
names.append(name)
out["unique_users"] = sorted(set(names))
return out
def normalize_report_body(body: str, host: Host | None, platform: Platform) -> str: def normalize_report_body(body: str, host: Host | None, platform: Platform) -> str:
"""Приводит текст отчёта (агент/SAC) к единому компактному виду.""" """Приводит текст отчёта (агент/SAC) к единому компактному виду."""
text = collapse_blank_lines(body.replace("\r\n", "\n")) text = collapse_blank_lines(body.replace("\r\n", "\n"))
@@ -422,5 +487,6 @@ def normalize_daily_report_details(
stats = out.get("stats") stats = out.get("stats")
if isinstance(stats, dict): if isinstance(stats, dict):
stats = enrich_stats_for_storage(platform, dict(stats)) stats = enrich_stats_for_storage(platform, dict(stats))
stats = reconcile_stats_from_report_body(stats, normalized_body, platform)
out["stats"] = stats out["stats"] = stats
return out return out
+18 -2
View File
@@ -3,6 +3,8 @@ from sqlalchemy.orm import Session
from app.models.event import Event from app.models.event import Event
from app.schemas.list_models import EventSummary from app.schemas.list_models import EventSummary
from app.services.event_actor_user import extract_event_actor_user from app.services.event_actor_user import extract_event_actor_user
from app.services.host_sessions import event_session_terminated
from app.services.rdg_display import build_rdg_display
from app.services.rdg_session_flap import resolve_rdg_flap_summary from app.services.rdg_session_flap import resolve_rdg_flap_summary
@@ -16,6 +18,17 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
db, event db, event
) )
title = event.title
summary = event.summary
rdg_access_path: str | None = None
rdg_qwinsta_enabled = False
rdg_display = build_rdg_display(event, db)
if rdg_display is not None:
title = rdg_display.title
summary = rdg_display.summary
rdg_access_path = rdg_display.access_path
rdg_qwinsta_enabled = rdg_display.qwinsta_enabled
return EventSummary( return EventSummary(
id=event.id, id=event.id,
event_id=event.event_id, event_id=event.event_id,
@@ -28,10 +41,13 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
category=event.category, category=event.category,
type=event.type, type=event.type,
severity=event.severity, severity=event.severity,
title=event.title, title=title,
summary=event.summary, summary=summary,
actor_user=extract_event_actor_user(event.type, event.details), actor_user=extract_event_actor_user(event.type, event.details),
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,
rdg_access_path=rdg_access_path,
rdg_qwinsta_enabled=rdg_qwinsta_enabled,
session_terminated=event_session_terminated(event, db=db),
) )
@@ -26,6 +26,14 @@ def show_in_events_for(event_type: str, *, hidden: frozenset[str]) -> bool:
return event_type not in hidden return event_type not in hidden
def event_type_notifications_enabled(event_type: str, db: Session | None) -> bool:
"""Outbound alerts (Telegram/push/email/webhook) only for types visible in events UI."""
if db is None:
return True
hidden = get_hidden_event_types(db)
return show_in_events_for(event_type, hidden=hidden)
def visibility_type_filter(hidden: frozenset[str]) -> ColumnElement[bool] | None: def visibility_type_filter(hidden: frozenset[str]) -> ColumnElement[bool] | None:
if not hidden: if not hidden:
return None return None
+132
View File
@@ -0,0 +1,132 @@
"""Best-effort integration with fail2ban for SSH login bans (OS level, not SAC DB)."""
from __future__ import annotations
import logging
import re
import subprocess
from dataclasses import dataclass
from app.config import get_settings
logger = logging.getLogger(__name__)
_BANNED_IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
@dataclass(frozen=True)
class Fail2banActionResult:
ok: bool
message: str
def _ssh_jail() -> str:
settings = get_settings()
jail = (getattr(settings, "sac_fail2ban_ssh_jail", None) or "sshd").strip()
return jail or "sshd"
def _run_fail2ban(args: list[str], *, timeout: int = 15) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["fail2ban-client", *args],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def fail2ban_available() -> bool:
try:
proc = _run_fail2ban(["ping"], timeout=5)
return proc.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def list_ssh_banned_ips() -> list[str]:
if not fail2ban_available():
return []
jail = _ssh_jail()
try:
proc = _run_fail2ban(["status", jail])
except (OSError, subprocess.SubprocessError) as exc:
logger.warning("fail2ban status failed: %s", exc)
return []
if proc.returncode != 0:
return []
text = (proc.stdout or "") + "\n" + (proc.stderr or "")
ips: list[str] = []
capture = False
for line in text.splitlines():
lower = line.strip().lower()
if "banned ip list" in lower:
capture = True
tail = line.split(":", 1)[-1]
ips.extend(_BANNED_IP_RE.findall(tail))
continue
if capture:
if not line.strip():
break
ips.extend(_BANNED_IP_RE.findall(line))
# preserve order, unique
seen: set[str] = set()
out: list[str] = []
for ip in ips:
if ip not in seen:
seen.add(ip)
out.append(ip)
return out
def unban_ssh_ip(ip_address: str) -> Fail2banActionResult:
ip = (ip_address or "").strip()
if not ip:
return Fail2banActionResult(ok=False, message="empty ip")
if not fail2ban_available():
return Fail2banActionResult(ok=False, message="fail2ban-client недоступен на сервере SAC")
jail = _ssh_jail()
try:
proc = _run_fail2ban(["set", jail, "unbanip", ip])
except (OSError, subprocess.SubprocessError) as exc:
return Fail2banActionResult(ok=False, message=str(exc))
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "unban failed").strip()
return Fail2banActionResult(ok=False, message=err[:500])
return Fail2banActionResult(ok=True, message=f"SSH: IP {ip} разблокирован (jail {jail})")
def sync_fail2ban_ignore_ips(ip_addresses: list[str]) -> Fail2banActionResult:
settings = get_settings()
if not getattr(settings, "sac_fail2ban_sync_enabled", False):
return Fail2banActionResult(ok=True, message="sync отключён (SAC_FAIL2BAN_SYNC_ENABLED=false)")
path = (getattr(settings, "sac_fail2ban_ignoreip_file", None) or "").strip()
if not path:
return Fail2banActionResult(ok=True, message="файл ignoreip не задан (SAC_FAIL2BAN_IGNOREIP_FILE)")
jail = _ssh_jail()
base_ignore = "127.0.0.1/8 ::1"
extra = " ".join(ip for ip in ip_addresses if ip)
ignore_line = f"{base_ignore} {extra}".strip()
content = (
f"# Managed by SAC — login IP whitelist for fail2ban\n"
f"[{jail}]\n"
f"ignoreip = {ignore_line}\n"
)
try:
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
except OSError as exc:
return Fail2banActionResult(ok=False, message=f"не удалось записать {path}: {exc}")
if not fail2ban_available():
return Fail2banActionResult(ok=True, message=f"файл записан ({path}); fail2ban reload пропущен")
try:
proc = _run_fail2ban(["reload"], timeout=30)
except (OSError, subprocess.SubprocessError) as exc:
return Fail2banActionResult(ok=False, message=f"файл записан, reload failed: {exc}")
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "reload failed").strip()
return Fail2banActionResult(ok=False, message=f"файл записан, reload: {err[:300]}")
return Fail2banActionResult(ok=True, message=f"fail2ban ignoreip обновлён ({path})")
+127
View File
@@ -0,0 +1,127 @@
"""Probe remote host and register in SAC before agent deploy (WinRM / SSH)."""
from __future__ import annotations
import re
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Host
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
from app.services.linux_admin_settings import get_effective_linux_admin_config
from app.services.ssh_connect import _is_ipv4, _ssh_output_hostname, test_ssh_connection
from app.services.win_admin_settings import get_effective_win_admin_config
from app.services.winrm_connect import test_winrm_connection
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
class ManualHostAddError(Exception):
pass
def validate_windows_target(target: str) -> str:
text = (target or "").strip()
if not text:
raise ManualHostAddError("Укажите имя Windows-ПК")
if _IPV4_RE.match(text):
raise ManualHostAddError(
"Для Windows укажите имя ПК (NetBIOS/DNS), не IP — WinRM с Kerberos по IP ненадёжен"
)
short = text.split(".")[0].split("\\")[-1].strip()
if not short or " " in short:
raise ManualHostAddError("Некорректное имя хоста")
return short
def validate_linux_target(target: str) -> str:
text = (target or "").strip()
if not text:
raise ManualHostAddError("Укажите IP или hostname Linux-хоста")
return text
def probe_windows_host(db: Session, hostname: str) -> str:
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise ManualHostAddError("Windows domain admin не настроен в SAC")
probe = test_winrm_connection(
target=hostname,
user=cfg.user,
password=cfg.password,
)
if not probe.ok:
raise ManualHostAddError(probe.message)
return (probe.hostname or hostname).strip()
def probe_linux_host(db: Session, target: str) -> tuple[str, str | None]:
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise ManualHostAddError("Linux SSH admin не настроен в SAC")
probe = test_ssh_connection(
target=target,
user=cfg.user,
password=cfg.password,
)
if not probe.ok:
raise ManualHostAddError(probe.message)
hostname = _ssh_output_hostname(probe.stdout) or target
ipv4 = target if _is_ipv4(target) else None
return hostname, ipv4
def get_or_create_manual_host(
db: Session,
*,
hostname: str,
product: str,
os_family: str,
ipv4: str | None = None,
) -> Host:
host = db.scalar(
select(Host).where(Host.hostname == hostname, Host.product == product).limit(1)
)
now = datetime.now(timezone.utc)
if host is None:
host = Host(
hostname=hostname,
os_family=os_family,
product=product,
ipv4=ipv4,
last_seen_at=now,
)
db.add(host)
else:
host.os_family = os_family
if ipv4:
host.ipv4 = ipv4
host.last_seen_at = now
db.flush()
return host
def prepare_manual_host_add(db: Session, *, platform: str, target: str) -> Host:
platform_norm = (platform or "").strip().lower()
if platform_norm == "windows":
win_target = validate_windows_target(target)
hostname = probe_windows_host(db, win_target)
return get_or_create_manual_host(
db,
hostname=hostname,
product=PRODUCT_RDP,
os_family="windows",
)
if platform_norm == "linux":
linux_target = validate_linux_target(target)
hostname, ipv4 = probe_linux_host(db, linux_target)
return get_or_create_manual_host(
db,
hostname=hostname,
product=PRODUCT_SSH,
os_family="linux",
ipv4=ipv4,
)
raise ManualHostAddError("platform должен быть windows или linux")
@@ -219,6 +219,59 @@ def run_agent_fallback_action(db: Session, host: Host) -> AgentUpdateFallbackRes
return execute_agent_update_fallback(db, host) return execute_agent_update_fallback(db, host)
def clear_stale_running_remote_actions(
db: Session,
*,
reason: str = "Прервано перезапуском SAC (фоновый worker не завершил job)",
host_ids: list[int] | None = None,
) -> list[str]:
"""Сбросить зависшие running после restart sac-api (daemon thread умер, запись в БД осталась)."""
from sqlalchemy import select
stmt = select(Host).where(Host.agent_update_state == "running")
if host_ids is not None:
stmt = stmt.where(Host.id.in_(host_ids))
rows = list(db.scalars(stmt).all())
if not rows:
return []
finished = _utcnow().isoformat()
hostnames: list[str] = []
for host in rows:
started = (host.remote_action or {}).get("started_at")
payload = dict(host.remote_action or {})
payload.update(
{
"title": payload.get("title") or "Remote action",
"status": "failed",
"message": reason,
"ok": False,
"finished_at": finished,
"started_at": started,
"target": payload.get("target") or host.hostname,
}
)
host.remote_action = payload
host.agent_update_state = "failed"
host.agent_update_last_error = reason[:2000]
hostnames.append(host.hostname or str(host.id))
db.commit()
with _lock:
for host in rows:
_running.discard(int(host.id))
return hostnames
def cancel_host_remote_action(db: Session, host: Host, *, reason: str | None = None) -> bool:
"""Отменить зависший running job вручную (admin)."""
if host.agent_update_state != "running":
return False
msg = reason or "Отменено администратором SAC"
cleared = clear_stale_running_remote_actions(db, reason=msg, host_ids=[int(host.id)])
return bool(cleared)
def get_remote_action_status(host: Host) -> dict[str, Any]: 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:
+513
View File
@@ -0,0 +1,513 @@
"""Live user sessions on hosts (Linux loginctl / Windows qwinsta) via SSH or WinRM."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.models import Event, Host
from app.services.event_actor_user import extract_event_actor_user
from app.services.linux_admin_settings import LinuxAdminConfig
from app.services.ssh_connect import (
HostNotLinuxError as SshHostNotLinuxError,
HostTargetMissingError as SshHostTargetMissingError,
SshCommandResult,
is_linux_host,
iter_ssh_targets,
run_ssh_command,
)
from app.services.win_admin_settings import WinAdminConfig
from app.services.winrm_connect import (
HostNotWindowsError,
HostTargetMissingError,
WinRmCmdResult,
is_windows_host,
run_winrm_logoff,
run_winrm_on_host_targets,
run_winrm_qwinsta,
)
SESSION_EVENT_TYPES_LINUX = frozenset(
{
"session.logind.new",
"ssh.login.success",
"privilege.sudo.command",
}
)
SESSION_EVENT_TYPES_WINDOWS = frozenset({"rdp.login.success"})
SESSION_TERMINATED_AT_KEY = "session_terminated_at"
SESSION_TERMINATED_BY_KEY = "session_terminated_by"
_LOGIND_SESSION_ID_RE = re.compile(r"^\d+$|^c\d+$", re.IGNORECASE)
_LOGIND_USER_RE = re.compile(r"^[a-z_][a-z0-9._-]*$", re.IGNORECASE)
_LOGIND_STATES = frozenset(
{
"active",
"online",
"closing",
"opening",
"degraded",
"lingering",
"preparing",
"unknown",
}
)
_LOGIND_TTY_RE = re.compile(r"^(pts|tty)/", re.IGNORECASE)
_NO_SESSION_MARKERS = ("no session", "unknown session", "does not exist")
@dataclass(frozen=True)
class HostSessionRow:
session_id: str
user: str
tty: str | None = None
state: str | None = None
source_ip: str | None = None
session_name: str | None = None
def _details_dict(event: Event) -> dict:
raw = event.details
return raw if isinstance(raw, dict) else {}
def _event_login_user(event: Event) -> str:
actor = extract_event_actor_user(event.type, event.details)
if actor:
return actor.strip()
return str(_details_dict(event).get("user") or "").strip()
def event_session_terminated(event: Event, db: Session | None = None) -> bool:
from app.services.rdg_workstation_session import (
event_closed_by_rdg,
resolve_workstation_login_closed,
)
details = _details_dict(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(event):
return True
if db is not None and event.type == "rdp.login.success":
return resolve_workstation_login_closed(db, event)
return False
def mark_event_session_terminated(event: Event, *, by_username: str | None = None) -> None:
details = dict(_details_dict(event))
details[SESSION_TERMINATED_AT_KEY] = datetime.now(timezone.utc).isoformat()
if by_username and by_username.strip():
details[SESSION_TERMINATED_BY_KEY] = by_username.strip()
event.details = details
def event_session_id(event: Event) -> str | None:
details = _details_dict(event)
for key in ("session_id", "sid"):
val = details.get(key)
if val is not None and str(val).strip():
return str(val).strip()
return None
def event_supports_session_terminate(event: Event) -> bool:
if event.type in SESSION_EVENT_TYPES_LINUX:
return is_linux_host_event(event)
if event.type in SESSION_EVENT_TYPES_WINDOWS:
return is_windows_host_event(event)
return False
def is_linux_host_event(event: Event) -> bool:
host = event.host
return host is not None and is_linux_host(host)
def is_windows_host_event(event: Event) -> bool:
host = event.host
return host is not None and is_windows_host(host)
def _normalize_logind_tty(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text or text == "-":
return None
return text
def _logind_row_priority(row: HostSessionRow) -> tuple[int, int]:
"""Prefer interactive TTY sessions over ephemeral scope/SSH-exec rows (tty «-»)."""
has_tty = 1 if row.tty and _LOGIND_TTY_RE.match(row.tty) else 0
try:
sid_num = int(row.session_id)
except ValueError:
sid_num = 0
return (has_tty, sid_num)
def filter_logind_session_rows(rows: list[HostSessionRow]) -> list[HostSessionRow]:
"""Drop no-TTY duplicates when the same user already has a pts/tty session."""
if len(rows) < 2:
return rows
by_user: dict[str, list[HostSessionRow]] = {}
for row in rows:
key = row.user.casefold()
by_user.setdefault(key, []).append(row)
out: list[HostSessionRow] = []
for group in by_user.values():
if len(group) == 1:
out.append(group[0])
continue
with_tty = [r for r in group if r.tty and _LOGIND_TTY_RE.match(r.tty)]
if with_tty:
out.append(max(with_tty, key=_logind_row_priority))
continue
out.append(max(group, key=_logind_row_priority))
out.sort(key=lambda r: (r.user.casefold(), -_logind_row_priority(r)[1]))
return out
def parse_loginctl_sessions_json(stdout: str) -> list[HostSessionRow]:
text = stdout.strip()
if not text:
return []
try:
payload = json.loads(text)
except json.JSONDecodeError:
return []
if not isinstance(payload, list):
return []
rows: list[HostSessionRow] = []
for item in payload:
if not isinstance(item, dict):
continue
sid = str(item.get("session") or "").strip()
user = str(item.get("user") or "").strip()
if not sid or not user:
continue
if not _LOGIND_SESSION_ID_RE.match(sid):
continue
if not _LOGIND_USER_RE.match(user):
continue
state = str(item.get("state") or "").strip().lower() or None
if state and state not in _LOGIND_STATES:
continue
rows.append(
HostSessionRow(
session_id=sid,
user=user,
tty=_normalize_logind_tty(item.get("tty")),
state=state,
)
)
return filter_logind_session_rows(rows)
def _looks_like_loginctl_row(parts: list[str]) -> bool:
if len(parts) < 6:
return False
sid, uid, user, state = parts[0], parts[1], parts[2], parts[5].lower()
if not _LOGIND_SESSION_ID_RE.match(sid):
return False
if not uid.isdigit():
return False
if not _LOGIND_USER_RE.match(user):
return False
return state in _LOGIND_STATES
def parse_loginctl_sessions(stdout: str) -> list[HostSessionRow]:
rows: list[HostSessionRow] = []
for line in stdout.splitlines():
text = line.strip()
if not text or text.startswith("SESSION"):
continue
parts = text.split()
if not _looks_like_loginctl_row(parts):
continue
sid, user = parts[0], parts[2]
tty = parts[4] if len(parts) > 4 and parts[4] != "-" else None
state = parts[5] if len(parts) > 5 else None
rows.append(
HostSessionRow(
session_id=sid,
user=user,
tty=_normalize_logind_tty(tty),
state=state,
)
)
return filter_logind_session_rows(rows)
def _shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def _linux_session_exists_message(stderr: str, stdout: str) -> bool:
blob = f"{stderr}\n{stdout}".casefold()
return any(marker in blob for marker in _NO_SESSION_MARKERS)
def _linux_show_session_user(host: Host, cfg: LinuxAdminConfig, session_id: str) -> str | None:
sid = session_id.strip()
if not sid:
return None
remote_cmd = f"loginctl show-session {_shell_quote(sid)} -p Name --value"
targets = iter_ssh_targets(host)
for target in targets:
result = run_ssh_command(
target=target,
user=cfg.user,
password=cfg.password,
remote_cmd=remote_cmd,
connect_timeout_sec=15,
command_timeout_sec=30,
need_root=True,
login_shell=False,
)
if not result.ok:
continue
user = result.stdout.strip().splitlines()[-1].strip() if result.stdout.strip() else ""
if user and _LOGIND_USER_RE.match(user):
return user
return None
def list_linux_sessions(host: Host, cfg: LinuxAdminConfig) -> tuple[list[HostSessionRow], SshCommandResult]:
if not is_linux_host(host):
raise SshHostNotLinuxError("Host is not Linux")
targets = iter_ssh_targets(host)
json_cmd = "loginctl list-sessions --output=json --no-legend"
text_cmd = "loginctl list-sessions --no-legend --no-pager"
last: SshCommandResult | None = None
for target in targets:
result = run_ssh_command(
target=target,
user=cfg.user,
password=cfg.password,
remote_cmd=json_cmd,
connect_timeout_sec=15,
command_timeout_sec=45,
need_root=True,
login_shell=False,
)
last = result
if result.ok and result.stdout.strip():
rows = parse_loginctl_sessions_json(result.stdout)
if rows:
return rows, result
rows = parse_loginctl_sessions(result.stdout)
if rows:
return rows, result
result = run_ssh_command(
target=target,
user=cfg.user,
password=cfg.password,
remote_cmd=text_cmd,
connect_timeout_sec=15,
command_timeout_sec=45,
need_root=True,
login_shell=False,
)
last = result
if result.ok and result.stdout.strip():
return parse_loginctl_sessions(result.stdout), result
assert last is not None
return [], last
def terminate_linux_session(
host: Host,
cfg: LinuxAdminConfig,
session_id: str,
) -> SshCommandResult:
sid = session_id.strip()
if not sid:
return SshCommandResult(ok=False, message="session_id is required", target="")
if not is_linux_host(host):
raise SshHostNotLinuxError("Host is not Linux")
targets = iter_ssh_targets(host)
remote_cmd = f"loginctl terminate-session {_shell_quote(sid)}"
last: SshCommandResult | None = None
session_user = _linux_show_session_user(host, cfg, sid)
for target in targets:
result = run_ssh_command(
target=target,
user=cfg.user,
password=cfg.password,
remote_cmd=remote_cmd,
connect_timeout_sec=15,
command_timeout_sec=45,
need_root=True,
login_shell=False,
)
last = result
if result.ok:
return result
if session_user:
return _terminate_linux_user_sessions(host, cfg, session_user)
if last is not None and _linux_session_exists_message(last.stderr, last.stdout):
return SshCommandResult(
ok=False,
message=(
f"Сессия {sid} не найдена (устарела). Обновите список и повторите "
"или завершите все сессии пользователя."
),
target=last.target,
stdout=last.stdout,
stderr=last.stderr,
exit_code=last.exit_code,
)
assert last is not None
return last
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]:
rows: list[HostSessionRow] = []
norm_filter = (filter_user or "").strip().lower()
def norm_user(value: str) -> str:
return value.replace("B26\\", "").replace("b26\\", "").lower()
for line in stdout.splitlines():
text = line.strip()
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
continue
parts = text.split()
if len(parts) < 4:
continue
session_name = parts[0].lstrip(">")
user_name = parts[1]
try:
sid = int(parts[2])
except ValueError:
continue
state = " ".join(parts[3:])
if norm_filter and norm_filter not in norm_user(user_name):
continue
rows.append(
HostSessionRow(
session_id=str(sid),
user=user_name,
session_name=session_name,
state=state,
)
)
if rows or not norm_filter:
return rows
return parse_qwinsta_sessions(stdout, filter_user=None)
def list_windows_sessions(host: Host, cfg: WinAdminConfig) -> tuple[list[HostSessionRow], WinRmCmdResult | None]:
if not is_windows_host(host):
raise HostNotWindowsError("Host is not Windows")
def action(target: str) -> WinRmCmdResult:
return run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password)
result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action)
if result is None or not result.ok:
return [], result
return parse_qwinsta_sessions(result.stdout), result
def terminate_windows_session(
host: Host,
cfg: WinAdminConfig,
session_id: str,
) -> WinRmCmdResult | None:
try:
sid = int(session_id.strip())
except ValueError:
return WinRmCmdResult(ok=False, message="Invalid Windows session_id", target="")
def action(target: str) -> WinRmCmdResult:
return run_winrm_logoff(
target=target,
user=cfg.user,
password=cfg.password,
session_id=sid,
)
result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action)
return result
def terminate_session_for_event(
event: Event,
*,
linux_cfg: LinuxAdminConfig,
win_cfg: WinAdminConfig,
session_id: str | None = None,
) -> SshCommandResult | WinRmCmdResult:
host = event.host
if host is None:
raise ValueError("Event has no host")
sid = (session_id or event_session_id(event) or "").strip()
if is_linux_host(host):
if not sid:
user = _event_login_user(event)
if user:
return _terminate_linux_user_sessions(host, linux_cfg, user)
raise ValueError("session_id is required for this event")
return terminate_linux_session(host, linux_cfg, sid)
if is_windows_host(host):
if not sid:
user = _event_login_user(event)
sessions, qwinsta = list_windows_sessions(host, win_cfg)
if not qwinsta or not qwinsta.ok:
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
if len(matched) == 1:
sid = matched[0].session_id
elif not matched:
raise ValueError("No matching Windows session for user")
else:
raise ValueError("Multiple sessions; specify session_id")
result = terminate_windows_session(host, win_cfg, sid)
if result is None:
raise ValueError("WinRM logoff failed")
return result
raise ValueError("Unsupported host OS for session terminate")
def _terminate_linux_user_sessions(host: Host, cfg: LinuxAdminConfig, user: str) -> SshCommandResult:
safe_user = _shell_quote(user.strip())
remote_cmd = f"loginctl terminate-user {safe_user}"
targets = iter_ssh_targets(host)
last: SshCommandResult | None = None
for target in targets:
result = run_ssh_command(
target=target,
user=cfg.user,
password=cfg.password,
remote_cmd=remote_cmd,
connect_timeout_sec=15,
command_timeout_sec=45,
need_root=True,
login_shell=False,
)
last = result
if result.ok:
return result
assert last is not None
return last
+134 -26
View File
@@ -6,16 +6,19 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import func, select, text
from sqlalchemy.exc import IntegrityError
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 import Host, Problem from app.models import Host, Problem
from app.services.problem_rules import RuleMatch, build_fingerprint, host_silence_match_for_host from app.services.problem_rules import RULE_HOST_SILENCE, RuleMatch, build_fingerprint, host_silence_match_for_host
from app.services.problems import _correlation_cutoff
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ACTIVE_HOST_SILENCE_STATUSES = ("open", "acknowledged")
_HOST_SILENCE_SCAN_LOCK_KEY = 915_001_001
@dataclass(frozen=True) @dataclass(frozen=True)
class HostSilenceScanResult: class HostSilenceScanResult:
@@ -25,45 +28,110 @@ class HostSilenceScanResult:
created: bool created: bool
def _host_silence_scan_lock(db: Session) -> bool:
"""Один scan за раз (uvicorn workers + systemd timer)."""
bind = db.get_bind()
if bind.dialect.name != "postgresql":
return True
acquired = db.execute(
text("SELECT pg_try_advisory_xact_lock(:key)"),
{"key": _HOST_SILENCE_SCAN_LOCK_KEY},
).scalar()
return bool(acquired)
def _find_active_host_silence_problem(
db: Session,
*,
host_id: int,
hostname: str | None,
) -> Problem | None:
active = db.scalar(
select(Problem)
.where(
Problem.host_id == host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status.in_(ACTIVE_HOST_SILENCE_STATUSES),
)
.order_by(Problem.last_seen_at.desc())
.limit(1)
)
if active is not None:
return active
name = (hostname or "").strip()
if not name:
return None
sibling = db.scalar(
select(Problem)
.join(Host, Problem.host_id == Host.id)
.where(
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status.in_(ACTIVE_HOST_SILENCE_STATUSES),
func.lower(Host.hostname) == name.lower(),
)
.order_by(Problem.last_seen_at.desc())
.limit(1)
)
if sibling is not None and sibling.host_id != host_id:
sibling.host_id = host_id
db.flush()
return sibling
def _apply_host_silence_refresh(
problem: Problem,
*,
host_id: int,
match: RuleMatch,
fingerprint: str,
ref: datetime,
) -> None:
problem.host_id = host_id
problem.summary = match.summary
problem.title = match.title
problem.fingerprint = fingerprint
problem.last_seen_at = ref
problem.updated_at = ref
def open_or_refresh_host_silence_problem( def open_or_refresh_host_silence_problem(
db: Session, db: Session,
host_id: int, host_id: int,
match: RuleMatch, match: RuleMatch,
*, *,
now: datetime | None = None, now: datetime | None = None,
hostname: str | None = None,
) -> tuple[Problem | None, bool]: ) -> tuple[Problem | None, bool]:
""" """
Открыть или обновить open Problem host_silence без ingest-события. Открыть или обновить open/ack Problem host_silence без ingest-события.
Returns (problem, created). problem is None when suppressed by manual-resolve cooldown. Returns (problem, created). problem is None when suppressed by manual-resolve cooldown.
""" """
ref = now or datetime.now(timezone.utc) ref = now or datetime.now(timezone.utc)
if hostname is None:
host = db.get(Host, host_id)
hostname = host.hostname if host is not None else None
fingerprint = build_fingerprint( fingerprint = build_fingerprint(
host_id, host_id,
match.correlation_type, match.correlation_type,
match.rule_id, match.rule_id,
match.fingerprint_suffix, match.fingerprint_suffix,
) )
cutoff = _correlation_cutoff(ref)
open_problem = db.scalar( active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
select(Problem) if active_problem is not None:
.where( _apply_host_silence_refresh(
Problem.status == "open", active_problem,
Problem.fingerprint == fingerprint, host_id=host_id,
Problem.host_id == host_id, match=match,
Problem.last_seen_at >= cutoff, fingerprint=fingerprint,
ref=ref,
) )
.order_by(Problem.last_seen_at.desc())
.limit(1)
)
if open_problem:
open_problem.summary = match.summary
open_problem.title = match.title
open_problem.last_seen_at = ref
open_problem.updated_at = ref
db.flush() db.flush()
return open_problem, False return active_problem, False
settings = get_settings() settings = get_settings()
cooldown_hours = settings.sac_host_silence_manual_resolve_cooldown_hours cooldown_hours = settings.sac_host_silence_manual_resolve_cooldown_hours
@@ -105,12 +173,27 @@ def open_or_refresh_host_silence_problem(
.limit(1) .limit(1)
) )
if manual_expired is not None: if manual_expired is not None:
active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
if active_problem is not None:
_apply_host_silence_refresh(
active_problem,
host_id=host_id,
match=match,
fingerprint=fingerprint,
ref=ref,
)
db.flush()
return active_problem, False
manual_expired.status = "open" manual_expired.status = "open"
manual_expired.resolved_by = None manual_expired.resolved_by = None
manual_expired.summary = match.summary _apply_host_silence_refresh(
manual_expired.title = match.title manual_expired,
manual_expired.last_seen_at = ref host_id=host_id,
manual_expired.updated_at = ref match=match,
fingerprint=fingerprint,
ref=ref,
)
db.flush() db.flush()
return manual_expired, True return manual_expired, True
@@ -125,13 +208,32 @@ def open_or_refresh_host_silence_problem(
event_count=0, event_count=0,
last_seen_at=ref, last_seen_at=ref,
) )
try:
with db.begin_nested():
db.add(problem) db.add(problem)
db.flush() db.flush()
except IntegrityError:
active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
if active_problem is None:
raise
_apply_host_silence_refresh(
active_problem,
host_id=host_id,
match=match,
fingerprint=fingerprint,
ref=ref,
)
db.flush()
return active_problem, False
return problem, True return problem, True
def run_host_silence_scan(db: Session, *, now: datetime | None = None) -> list[HostSilenceScanResult]: def run_host_silence_scan(db: Session, *, now: datetime | None = None) -> list[HostSilenceScanResult]:
"""Найти stale-хосты и создать/обновить Problems rule:host_silence.""" """Найти stale-хосты и создать/обновить Problems rule:host_silence."""
if not _host_silence_scan_lock(db):
logger.debug("host_silence scan skipped (another runner holds advisory lock)")
return []
ref = now or datetime.now(timezone.utc) ref = now or datetime.now(timezone.utc)
hosts = db.scalars(select(Host).order_by(Host.id)).all() hosts = db.scalars(select(Host).order_by(Host.id)).all()
results: list[HostSilenceScanResult] = [] results: list[HostSilenceScanResult] = []
@@ -140,7 +242,13 @@ def run_host_silence_scan(db: Session, *, now: datetime | None = None) -> list[H
match = host_silence_match_for_host(db, host.id, hostname=host.hostname, now=ref) match = host_silence_match_for_host(db, host.id, hostname=host.hostname, now=ref)
if match is None: if match is None:
continue continue
problem, created = open_or_refresh_host_silence_problem(db, host.id, match, now=ref) problem, created = open_or_refresh_host_silence_problem(
db,
host.id,
match,
now=ref,
hostname=host.hostname,
)
if problem is None: if problem is None:
continue continue
results.append( results.append(
+2
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.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"})
@@ -126,4 +127,5 @@ 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)
close_workstation_session_for_rdg_end(db, event)
return event, True return event, True
+10
View File
@@ -12,6 +12,10 @@ from sqlalchemy.orm import Session
from app.config import get_settings from app.config import get_settings
from app.models.login_attempt import LoginAttempt from app.models.login_attempt import LoginAttempt
from app.services.login_security_settings import (
get_effective_login_security_config,
is_ip_login_whitelisted,
)
from app.services.telegram_notify import send_telegram_text from app.services.telegram_notify import send_telegram_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -91,6 +95,9 @@ def ensure_login_allowed(db: Session, request: Request) -> str:
"""Raise 429 if IP exceeded failed login threshold. Returns client IP.""" """Raise 429 if IP exceeded failed login threshold. Returns client IP."""
cfg = get_login_rate_limit_config() cfg = get_login_rate_limit_config()
ip_address = client_ip_from_request(request) ip_address = client_ip_from_request(request)
sec = get_effective_login_security_config(db)
if is_ip_login_whitelisted(ip_address, sec.ip_whitelist):
return ip_address
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes) since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
failures = _failure_count(db, ip_address, since=since) failures = _failure_count(db, ip_address, since=since)
if failures >= cfg.max_failures: if failures >= cfg.max_failures:
@@ -119,6 +126,9 @@ def record_login_failure(
request: Request | None = None, request: Request | None = None,
) -> None: ) -> None:
del request # reserved del request # reserved
sec = get_effective_login_security_config(db)
if is_ip_login_whitelisted(ip_address, sec.ip_whitelist):
return
cfg = get_login_rate_limit_config() cfg = get_login_rate_limit_config()
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes) since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
prior_failures = _failure_count(db, ip_address, since=since) prior_failures = _failure_count(db, ip_address, since=since)
@@ -0,0 +1,254 @@
"""SAC UI login security: IP whitelist, blocked list, unblock."""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.login_attempt import LoginAttempt
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
from app.services.fail2ban_sync import (
Fail2banActionResult,
list_ssh_banned_ips,
sync_fail2ban_ignore_ips,
unban_ssh_ip,
)
_IP_RE = re.compile(
r"^(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)$"
)
@dataclass(frozen=True)
class LoginSecurityConfig:
ip_whitelist: tuple[str, ...]
sync_fail2ban: bool
max_failures: int
window_minutes: int
source: str
@dataclass(frozen=True)
class LoginBlockEntry:
ip_address: str
scope: str
failure_count: int | None
usernames: tuple[str, ...]
blocked_until: datetime | None
reason: str
def normalize_ip_token(raw: str) -> str | None:
text = (raw or "").strip()
if not text or text.startswith("#"):
return None
if _IP_RE.match(text):
return text
return None
def parse_ip_whitelist_text(text: str) -> list[str]:
if not text:
return []
tokens = re.split(r"[\s,;]+", text.replace("\n", " "))
out: list[str] = []
seen: set[str] = set()
for token in tokens:
ip = normalize_ip_token(token)
if ip and ip not in seen:
seen.add(ip)
out.append(ip)
return out
def _env_whitelist() -> list[str]:
settings = get_settings()
raw = (getattr(settings, "sac_login_ip_whitelist", None) or "").strip()
return parse_ip_whitelist_text(raw.replace(",", " "))
def _db_whitelist(row: UiSettings | None) -> list[str]:
if row is None:
return []
return parse_ip_whitelist_text(row.login_ip_whitelist or "")
def merge_whitelist(*parts: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for part in parts:
for ip in part:
if ip not in seen:
seen.add(ip)
out.append(ip)
return out
def get_effective_login_security_config(db: Session) -> LoginSecurityConfig:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
env_ips = _env_whitelist()
db_ips = _db_whitelist(row)
merged = merge_whitelist(env_ips, db_ips)
settings = get_settings()
max_failures = max(1, int(getattr(settings, "sac_login_max_failures", 3) or 3))
window_minutes = max(1, int(getattr(settings, "sac_login_failure_window_minutes", 15) or 15))
sources: list[str] = []
if env_ips:
sources.append("env")
if db_ips:
sources.append("db")
source = "+".join(sources) if sources else "default"
sync_fb = bool(row.login_sync_fail2ban) if row is not None else False
return LoginSecurityConfig(
ip_whitelist=tuple(merged),
sync_fail2ban=sync_fb,
max_failures=max_failures,
window_minutes=window_minutes,
source=source,
)
def is_ip_login_whitelisted(ip_address: str, whitelist: tuple[str, ...] | list[str]) -> bool:
ip = (ip_address or "").strip()
if not ip:
return False
return ip in whitelist
def upsert_login_security_settings(
db: Session,
*,
ip_whitelist_text: str,
sync_fail2ban: bool,
) -> LoginSecurityConfig:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
db.add(row)
row.login_ip_whitelist = ip_whitelist_text.strip() or None
row.login_sync_fail2ban = bool(sync_fail2ban)
db.commit()
db.refresh(row)
cfg = get_effective_login_security_config(db)
if cfg.sync_fail2ban:
sync_fail2ban_ignore_ips(list(cfg.ip_whitelist))
return cfg
def _window_since(cfg: LoginSecurityConfig) -> datetime:
return datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
def list_web_login_blocks(db: Session) -> list[LoginBlockEntry]:
cfg = get_effective_login_security_config(db)
since = _window_since(cfg)
rows = db.execute(
select(
LoginAttempt.ip_address,
func.count().label("cnt"),
func.max(LoginAttempt.created_at).label("last_at"),
)
.where(
LoginAttempt.success.is_(False),
LoginAttempt.username != "__alert_sent__",
LoginAttempt.created_at >= since,
)
.group_by(LoginAttempt.ip_address)
.having(func.count() >= cfg.max_failures)
).all()
blocks: list[LoginBlockEntry] = []
for ip_address, cnt, last_at in rows:
ip = str(ip_address)
if is_ip_login_whitelisted(ip, cfg.ip_whitelist):
continue
user_rows = db.scalars(
select(LoginAttempt.username)
.where(
LoginAttempt.ip_address == ip,
LoginAttempt.success.is_(False),
LoginAttempt.username.isnot(None),
LoginAttempt.username != "__alert_sent__",
LoginAttempt.created_at >= since,
)
.distinct()
.limit(10)
).all()
usernames = tuple(sorted({u for u in user_rows if u}))
blocked_until = (last_at + timedelta(minutes=cfg.window_minutes)) if last_at else None
blocks.append(
LoginBlockEntry(
ip_address=ip,
scope="web",
failure_count=int(cnt or 0),
usernames=usernames,
blocked_until=blocked_until,
reason=f"{cfg.max_failures} неудачных входов в UI за {cfg.window_minutes} мин",
)
)
return blocks
def list_ssh_login_blocks() -> list[LoginBlockEntry]:
banned = list_ssh_banned_ips()
return [
LoginBlockEntry(
ip_address=ip,
scope="ssh",
failure_count=None,
usernames=(),
blocked_until=None,
reason="fail2ban (sshd)",
)
for ip in banned
]
def list_all_login_blocks(db: Session) -> list[LoginBlockEntry]:
web = {b.ip_address: b for b in list_web_login_blocks(db)}
ssh = list_ssh_login_blocks()
out = list(web.values())
for entry in ssh:
if entry.ip_address in web:
existing = web[entry.ip_address]
out[out.index(existing)] = LoginBlockEntry(
ip_address=entry.ip_address,
scope="web+ssh",
failure_count=existing.failure_count,
usernames=existing.usernames,
blocked_until=existing.blocked_until,
reason=f"{existing.reason}; {entry.reason}",
)
else:
out.append(entry)
out.sort(key=lambda e: e.ip_address)
return out
def clear_web_login_block(db: Session, ip_address: str) -> int:
ip = (ip_address or "").strip()
if not ip:
return 0
result = db.execute(delete(LoginAttempt).where(LoginAttempt.ip_address == ip))
db.commit()
return int(result.rowcount or 0)
def unblock_login_ip(db: Session, ip_address: str, *, scope: str = "both") -> list[str]:
ip = (ip_address or "").strip()
if not ip:
return ["empty ip"]
messages: list[str] = []
scope_norm = (scope or "both").strip().lower()
if scope_norm in ("web", "both"):
deleted = clear_web_login_block(db, ip)
messages.append(f"Web UI: удалено записей login_attempts: {deleted}")
if scope_norm in ("ssh", "both"):
res: Fail2banActionResult = unban_ssh_ip(ip)
messages.append(res.message)
return messages
+39
View File
@@ -8,6 +8,7 @@ from app.models import Event, 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
from app.services.event_type_visibility import event_type_notifications_enabled
from app.services.host_health import HEARTBEAT_TYPE from app.services.host_health import HEARTBEAT_TYPE
from app.services.notification_severity import severity_meets_minimum from app.services.notification_severity import severity_meets_minimum
@@ -29,6 +30,17 @@ def _event_telegram_via_agent(event: Event) -> bool:
return via == "agent" return via == "agent"
def _skip_notifications_for_hidden_event(event: Event, db: Session | None) -> bool:
if event_type_notifications_enabled(event.type, db):
return False
logger.info(
"notify skipped hidden event type=%s event_id=%s",
event.type,
event.event_id,
)
return True
def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None: def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None:
if policy.use_telegram: if policy.use_telegram:
telegram_notify.notify_event(event, db=db, apply_policy_gate=False) telegram_notify.notify_event(event, db=db, apply_policy_gate=False)
@@ -55,6 +67,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 _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db) policy = get_effective_notification_policy(db)
if not severity_meets_minimum(event.severity, policy.min_severity): if not severity_meets_minimum(event.severity, policy.min_severity):
return return
@@ -89,6 +103,8 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует. При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
""" """
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db) policy = get_effective_notification_policy(db)
if not should_notify_event(event, db): if not should_notify_event(event, db):
return return
@@ -97,6 +113,8 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
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 _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db) policy = get_effective_notification_policy(db)
if not should_notify_event(event, db): if not should_notify_event(event, db):
return return
@@ -108,6 +126,8 @@ def notify_auth_login(event: Event, *, db: Session | None = None) -> None:
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует. При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
""" """
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db) policy = get_effective_notification_policy(db)
if not should_notify_event(event, db): if not should_notify_event(event, db):
return return
@@ -116,7 +136,26 @@ def notify_auth_login(event: Event, *, db: Session | None = None) -> None:
def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None: def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None:
"""RD Gateway 302/303 — ingest всегда; Telegram SAC вне min_severity (как auth login).""" """RD Gateway 302/303 — ingest всегда; Telegram SAC вне min_severity (как auth login)."""
if _skip_notifications_for_hidden_event(event, db):
return
policy = get_effective_notification_policy(db) policy = get_effective_notification_policy(db)
if not should_notify_event(event, db): if not should_notify_event(event, db):
return return
_dispatch_lifecycle_channels(event, db=db, policy=policy) _dispatch_lifecycle_channels(event, db=db, policy=policy)
def schedule_notify_daily_report(event_db_id: int) -> None:
"""Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST)."""
from app.database import SessionLocal
db = SessionLocal()
try:
event = db.get(Event, event_db_id)
if event is None:
logger.warning("deferred daily report notify: event id=%s not found", event_db_id)
return
notify_daily_report(event, db=db)
except Exception:
logger.exception("deferred daily report notify failed event_db_id=%s", event_db_id)
finally:
db.close()
+1 -1
View File
@@ -51,7 +51,7 @@ def open_or_append_problem(
if ref.tzinfo is None: if ref.tzinfo is None:
ref = ref.replace(tzinfo=timezone.utc) ref = ref.replace(tzinfo=timezone.utc)
problem, created = open_or_refresh_host_silence_problem( problem, created = open_or_refresh_host_silence_problem(
db, event.host_id, match, now=ref db, event.host_id, match, now=ref, hostname=event.host.hostname if event.host else None
) )
if problem is None: if problem is None:
return None, False return None, False
+132
View File
@@ -0,0 +1,132 @@
"""RD Gateway event labels (access path, UI title/summary)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Event
from app.services.rdg_client_host import find_windows_host_by_ipv4
from app.services.rdg_session_flap import event_internal_ip, resolve_rdg_qwinsta_enabled
RDG_TYPES = frozenset(
{
"rdg.connection.success",
"rdg.connection.disconnected",
"rdg.connection.failed",
}
)
ACCESS_PATH_HAPROXY = "Haproxy-RDG-Comp"
ACCESS_PATH_DIRECT = "RDG-Comp"
@dataclass(frozen=True)
class RdgDisplayInfo:
title: str
summary: str
access_path: str | None
internal_ip: str | None
qwinsta_enabled: bool
def _details_dict(event: Event) -> dict:
raw = event.details
return raw if isinstance(raw, dict) else {}
def _event_external_ip(event: Event) -> str:
details = _details_dict(event)
for key in ("external_ip", "source_ip", "ip_address"):
val = details.get(key)
if val is not None and str(val).strip():
return str(val).strip()
return ""
def _parse_haproxy_ips() -> frozenset[str]:
raw = (get_settings().sac_rdg_haproxy_external_ips or "").strip()
if not raw:
return frozenset()
parts = [p.strip() for p in raw.replace(";", ",").split(",")]
return frozenset(p for p in parts if p)
def classify_rdg_access_path(external_ip: str) -> str | None:
ip = (external_ip or "").strip()
if not ip or ip in ("-", "N/A"):
return None
if ip in _parse_haproxy_ips():
return ACCESS_PATH_HAPROXY
return ACCESS_PATH_DIRECT
def _windows_event_label(event: Event) -> str:
details = _details_dict(event)
win_id = details.get("event_id_windows")
if win_id is None:
return ""
text = str(win_id).strip()
return f"event {text}" if text else ""
def _action_label(event: Event) -> str:
if event.type == "rdg.connection.success":
return "подключение"
if event.type == "rdg.connection.disconnected":
return "отключение"
return "ошибка"
def build_rdg_display(event: Event, db: Session | None = None) -> RdgDisplayInfo | None:
if event.type not in RDG_TYPES:
return None
details = _details_dict(event)
internal_ip = event_internal_ip(event)
external_ip = _event_external_ip(event)
access_path = classify_rdg_access_path(external_ip)
user = str(details.get("user") or "").strip()
gateway = event.host.hostname if event.host else ""
client_label = internal_ip or ""
if db and internal_ip:
client_host = find_windows_host_by_ipv4(db, internal_ip)
if client_host is not None:
client_label = f"{client_host.hostname} ({internal_ip})"
path_note = f" ({access_path})" if access_path else ""
win_note = _windows_event_label(event)
action = _action_label(event)
title = f"RDS {action}{client_label}{path_note}"
if win_note:
title = f"{title} · {win_note}"
summary_parts: list[str] = []
if user:
summary_parts.append(user)
if external_ip:
summary_parts.append(f"внешний {external_ip}")
summary_parts.append(f"шлюз {gateway}")
if win_note:
summary_parts.append(win_note)
summary = " · ".join(summary_parts)
qwinsta_enabled = resolve_rdg_qwinsta_enabled(db, event)
return RdgDisplayInfo(
title=title,
summary=summary,
access_path=access_path,
internal_ip=internal_ip or None,
qwinsta_enabled=qwinsta_enabled,
)
def event_supports_rdg_client_qwinsta(event: Event, db: Session | None = None) -> bool:
from app.services.rdg_session_flap import resolve_rdg_qwinsta_enabled
return resolve_rdg_qwinsta_enabled(db, event)
+118 -3
View File
@@ -47,6 +47,15 @@ def _internal_ips_compatible(end_event: Event, success_event: Event) -> bool:
return end_ip == success_ip return end_ip == success_ip
def _internal_ips_match_strict(end_event: Event, success_event: Event) -> bool:
"""Для «сессия завершена» — только при совпадении целевого ПК (оба IP заданы)."""
end_ip = _event_internal_ip(end_event)
success_ip = _event_internal_ip(success_event)
if not end_ip or not success_ip:
return False
return end_ip == success_ip
def _as_utc(dt: datetime) -> datetime: def _as_utc(dt: datetime) -> datetime:
if dt.tzinfo is None: if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc) return dt.replace(tzinfo=timezone.utc)
@@ -172,25 +181,131 @@ def find_rdg_end_after_success(db: Session, success_event: Event) -> Event | Non
return None return None
def find_normal_rdg_end_after_success(db: Session, success_event: Event) -> Event | None:
"""303 после 302 с паузой больше flap-окна — штатное завершение сессии."""
if success_event.type != RDG_SUCCESS_TYPE:
return None
settings = get_settings()
max_sec = settings.sac_rdg_flap_window_max_sec
start_at = _as_utc(success_event.occurred_at)
after_flap = start_at + timedelta(seconds=max_sec)
candidates = db.scalars(
select(Event)
.where(
Event.host_id == success_event.host_id,
Event.type.in_(RDG_END_TYPES),
Event.occurred_at > after_flap,
Event.id != success_event.id,
)
.order_by(Event.occurred_at.asc())
).all()
for end in candidates:
if not _users_match(end, success_event):
continue
if not _internal_ips_match_strict(end, success_event):
continue
return end
return None
def _users_and_ip_match(left: Event, right: Event) -> bool:
return _users_match(left, right) and _internal_ips_match_strict(left, right)
def find_later_rdg_success_after(
db: Session,
*,
anchor: Event,
after: datetime,
) -> Event | None:
"""Поздний 302 на том же шлюзе, user и client PC — пользователь снова зашёл через RDG."""
if anchor.type != RDG_SUCCESS_TYPE:
return None
after_at = _as_utc(after)
candidates = db.scalars(
select(Event)
.where(
Event.host_id == anchor.host_id,
Event.type == RDG_SUCCESS_TYPE,
Event.occurred_at > after_at,
Event.id != anchor.id,
)
.order_by(Event.occurred_at.asc())
).all()
for later in candidates:
if not _users_and_ip_match(later, anchor):
continue
return later
return None
def _flap_auto_disconnect_succeeded(flap_end: Event) -> bool:
details = flap_end.details if isinstance(flap_end.details, dict) else {}
block = details.get("rdp_flap_auto_disconnect")
if not isinstance(block, dict):
return False
return block.get("ok") is True
def _flap_workstation_session_closed(db: Session, flap_end: Event) -> bool:
from app.services.host_sessions import event_session_terminated
from app.services.rdg_workstation_session import find_workstation_login_for_rdg_end
login = find_workstation_login_for_rdg_end(db, flap_end)
if login is None:
return False
return event_session_terminated(login, db=db)
def resolve_rdg_qwinsta_enabled(db: Session | None, event: Event) -> bool:
"""Кнопка qwinsta/logoff только на 302, пока сессия может быть активна (или RDG flap)."""
if event.type in RDG_END_TYPES:
return False
if event.type != RDG_SUCCESS_TYPE:
return False
if not _event_internal_ip(event):
return False
if db is None:
return True
flap_end = find_rdg_end_after_success(db, event)
if flap_end is not None:
if _flap_auto_disconnect_succeeded(flap_end):
return False
if _flap_workstation_session_closed(db, flap_end):
return False
if find_later_rdg_success_after(db, anchor=event, after=flap_end.occurred_at) is not None:
return False
return True
if find_normal_rdg_end_after_success(db, event) is not None:
return False
return True
def resolve_rdg_flap_summary( def resolve_rdg_flap_summary(
db: Session, event: Event db: Session, event: Event
) -> tuple[bool, int | None, int | None]: ) -> tuple[bool, int | None, int | None]:
""" """
(rdg_flap, pair_event_id, qwinsta_event_id). (rdg_flap, pair_event_id, qwinsta_event_id).
qwinsta_event_id — всегда 303; для 302 указывает на связанный end-event. qwinsta_event_id — id события 302 для qwinsta (на 303 кнопку не показываем).
""" """
if event_has_rdg_flap(event): if event_has_rdg_flap(event):
pair_id = _stored_flap_pair_id(event) pair_id = _stored_flap_pair_id(event)
if event.type == RDG_SUCCESS_TYPE:
return True, pair_id, event.id return True, pair_id, event.id
return True, pair_id, pair_id
if event.type in RDG_END_TYPES: if event.type in RDG_END_TYPES:
prior = find_rdg_success_before_end(db, event) prior = find_rdg_success_before_end(db, event)
if prior is not None: if prior is not None:
return True, prior.id, event.id return True, prior.id, prior.id
if event.type == RDG_SUCCESS_TYPE: if event.type == RDG_SUCCESS_TYPE:
end = find_rdg_end_after_success(db, event) end = find_rdg_end_after_success(db, event)
if end is not None: if end is not None:
return True, end.id, end.id return True, end.id, event.id
return False, None, None return False, None, None
+10 -9
View File
@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
from app.models import AgentCommand, Event, Host 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_session_flap import event_has_rdg_flap, event_internal_ip, resolve_rdg_flap_summary from app.services.rdg_display import event_supports_rdg_client_qwinsta
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_config
from app.services.winrm_connect import ( from app.services.winrm_connect import (
WinRmCmdResult, WinRmCmdResult,
@@ -30,13 +31,13 @@ def _require_win_admin(db: Session):
return cfg return cfg
def _require_rdg_flap(db: Session, event: Event) -> None: def _require_rdg_client_qwinsta(db: Session, event: Event) -> None:
flap, _, qwinsta_id = resolve_rdg_flap_summary(db, event) if event_supports_rdg_client_qwinsta(event, db):
if flap and qwinsta_id == event.id:
return return
if event_has_rdg_flap(event): raise HTTPException(
return status_code=400,
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap") detail="Event is not an RD Gateway connection with internal_ip (client workstation)",
)
def _resolve_client(db: Session, event: Event) -> Host: def _resolve_client(db: Session, event: Event) -> Host:
@@ -91,7 +92,7 @@ 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_flap(db, event) _require_rdg_client_qwinsta(db, event)
cfg = _require_win_admin(db) cfg = _require_win_admin(db)
client_host = _resolve_client(db, event) client_host = _resolve_client(db, event)
@@ -134,7 +135,7 @@ def execute_logoff_via_winrm(
session_id: int, session_id: int,
requested_by: str, requested_by: str,
) -> AgentCommand: ) -> AgentCommand:
_require_rdg_flap(db, event) _require_rdg_client_qwinsta(db, event)
cfg = _require_win_admin(db) cfg = _require_win_admin(db)
client_host = _resolve_client(db, event) client_host = _resolve_client(db, event)
@@ -0,0 +1,159 @@
"""Correlate RDG 303/303-failed with workstation rdp.login.success (1149)."""
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_client_host import find_windows_host_by_ipv4
from app.services.rdg_session_flap import (
RDG_END_TYPES,
_event_user,
event_internal_ip,
find_rdg_success_before_end,
)
SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at"
SESSION_CLOSED_BY_RDG_EVENT_ID_KEY = "session_closed_by_rdg_event_id"
WORKSTATION_LOGIN_TYPE = "rdp.login.success"
def normalize_sam_account(user: str) -> str:
text = (user or "").strip()
if "\\" in text:
return text.split("\\")[-1].strip().casefold()
if "@" in text:
return text.split("@")[0].strip().casefold()
return text.casefold()
def users_match_rdg(login_user: str, rdg_user: str) -> bool:
left = normalize_sam_account(login_user)
right = normalize_sam_account(rdg_user)
return bool(left and right and left == right)
def event_closed_by_rdg(event: Event) -> bool:
details = _details_dict(event)
at = details.get(SESSION_CLOSED_BY_RDG_AT_KEY)
return at is not None and str(at).strip() != ""
def mark_login_closed_by_rdg(login_event: Event, *, rdg_end_event: Event) -> None:
details = dict(_details_dict(login_event))
details[SESSION_CLOSED_BY_RDG_AT_KEY] = rdg_end_event.occurred_at.isoformat()
details[SESSION_CLOSED_BY_RDG_EVENT_ID_KEY] = rdg_end_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
return event_closed_by_rdg(login_event)
def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
if rdg_end_event.type not in RDG_END_TYPES:
return None
internal_ip = event_internal_ip(rdg_end_event)
if not internal_ip:
return None
client_host = find_windows_host_by_ipv4(db, internal_ip)
if client_host is None:
return None
rdg_user = _event_user(rdg_end_event)
if not rdg_user:
return None
end_at = rdg_end_event.occurred_at
candidates = db.scalars(
select(Event)
.where(
Event.host_id == client_host.id,
Event.type == WORKSTATION_LOGIN_TYPE,
Event.occurred_at <= end_at,
Event.id != rdg_end_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
for login in candidates:
if _login_already_closed(login):
continue
login_user = _event_login_user(login)
if not users_match_rdg(login_user, rdg_user):
continue
return login
return None
def find_rdg_end_after_workstation_login(db: Session, login_event: Event) -> Event | None:
"""Runtime lookup for historical events without persisted close flag."""
if login_event.type != WORKSTATION_LOGIN_TYPE:
return None
if _login_already_closed(login_event):
return None
host = login_event.host
if host is None or not host.ipv4:
return None
workstation_ip = host.ipv4.strip()
login_user = _event_login_user(login_event)
if not login_user:
return None
login_at = login_event.occurred_at
candidates = db.scalars(
select(Event)
.where(
Event.type.in_(RDG_END_TYPES),
Event.occurred_at >= login_at,
Event.id != login_event.id,
)
.order_by(Event.occurred_at.asc())
).all()
for end in candidates:
if event_internal_ip(end) != workstation_ip:
continue
if not users_match_rdg(_event_user(end), login_user):
continue
if find_rdg_success_before_end(db, end) is not None:
continue
return end
return None
def resolve_workstation_login_closed(db: Session, login_event: Event) -> bool:
if event_closed_by_rdg(login_event):
return True
return find_rdg_end_after_workstation_login(db, login_event) is not None
def close_workstation_session_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
"""On RDG disconnect, mark matching workstation login as session-closed."""
if rdg_end_event.type not in RDG_END_TYPES:
return None
if find_rdg_success_before_end(db, rdg_end_event) is not None:
return None
login = find_workstation_login_for_rdg_end(db, rdg_end_event)
if login is None:
return None
mark_login_closed_by_rdg(login, rdg_end_event=rdg_end_event)
return login
@@ -0,0 +1,320 @@
"""Auto logoff stuck RDP sessions when RDG flap (or direct login failure) is detected."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import Event, Host
from app.services.host_sessions import (
list_windows_sessions,
mark_event_session_terminated,
parse_qwinsta_sessions,
terminate_windows_session,
)
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
from app.services.rdg_session_flap import (
RDG_END_TYPES,
RDG_SUCCESS_TYPE,
_event_user,
_stored_flap_pair_id,
event_has_rdg_flap,
find_rdg_success_before_end,
)
from app.services.rdg_winrm_actions import execute_logoff_via_winrm
from app.services.rdg_workstation_session import (
WORKSTATION_LOGIN_TYPE,
_event_login_user,
_login_already_closed,
find_workstation_login_for_rdg_end,
users_match_rdg,
)
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings
from app.services.win_admin_settings import get_effective_win_admin_config
logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
AUTO_DISCONNECT_BY = "auto:rdp_flap"
RDP_LOGIN_FAILED = "rdp.login.failed"
AUTO_DISCONNECT_DETAILS_KEY = "rdp_flap_auto_disconnect"
@dataclass(frozen=True)
class AutoDisconnectResult:
ok: bool
message: str
trigger_event_id: int
workstation_host_id: int | None = None
login_event_id: int | None = None
session_ids: tuple[int, ...] = ()
def _details_dict(event: Event) -> dict:
raw = event.details
return raw if isinstance(raw, dict) else {}
def _already_auto_disconnected(event: Event) -> bool:
details = _details_dict(event)
block = details.get(AUTO_DISCONNECT_DETAILS_KEY)
if not isinstance(block, dict):
return False
return block.get("ok") is True
def _mark_auto_disconnect(event: Event, *, result: AutoDisconnectResult) -> None:
details = dict(_details_dict(event))
details[AUTO_DISCONNECT_DETAILS_KEY] = {
"ok": result.ok,
"message": result.message,
"workstation_host_id": result.workstation_host_id,
"login_event_id": result.login_event_id,
"session_ids": list(result.session_ids),
"at": datetime.now(timezone.utc).isoformat(),
}
event.details = details
flag_modified(event, "details")
def _norm_user_filter(user: str) -> str:
text = (user or "").strip()
if "\\" in text:
return text.split("\\")[-1].strip().lower()
if "@" in text:
return text.split("@")[0].strip().lower()
return text.lower()
def _sessions_for_user(sessions, user: str):
needle = _norm_user_filter(user)
if not needle:
return []
matched = []
for row in sessions:
if needle in _norm_user_filter(row.user):
matched.append(row)
return matched
def find_open_workstation_login(db: Session, *, host_id: int, user: str) -> Event | None:
if not user.strip():
return None
candidates = db.scalars(
select(Event)
.where(
Event.host_id == host_id,
Event.type == WORKSTATION_LOGIN_TYPE,
)
.order_by(Event.occurred_at.desc())
).all()
for login in candidates:
if _login_already_closed(login):
continue
if users_match_rdg(_event_login_user(login), user):
return login
return None
def _rdg_pair_success_event(db: Session, event: Event) -> Event | None:
if event.type == RDG_SUCCESS_TYPE and event_has_rdg_flap(event):
pair_id = _stored_flap_pair_id(event)
if pair_id is not None:
return db.get(Event, pair_id)
return event
if event.type in RDG_END_TYPES:
pair_id = _stored_flap_pair_id(event)
if pair_id is not None:
return db.get(Event, pair_id)
return find_rdg_success_before_end(db, event)
return None
def _disconnect_on_workstation(
db: Session,
*,
trigger_event: Event,
workstation: Host,
rdg_event: Event | None,
user: str,
login_event: Event | None,
) -> AutoDisconnectResult:
win_cfg = get_effective_win_admin_config(db)
sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
if qwinsta is None or not qwinsta.ok:
message = qwinsta.message if qwinsta else "qwinsta failed"
return AutoDisconnectResult(
ok=False,
message=message,
trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None,
)
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout, filter_user=user), user)
if not matched and qwinsta.stdout.strip():
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout), user)
if not matched:
return AutoDisconnectResult(
ok=False,
message="No matching Windows session for user",
trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None,
)
logged_off: list[int] = []
errors: list[str] = []
for row in matched:
if rdg_event is not None:
cmd = execute_logoff_via_winrm(
db,
rdg_event,
session_id=int(row.session_id),
requested_by=AUTO_DISCONNECT_BY,
)
if cmd.status == "completed":
logged_off.append(int(row.session_id))
else:
errors.append(cmd.result_stderr or cmd.result_stdout or f"logoff {row.session_id} failed")
else:
result = terminate_windows_session(workstation, win_cfg, row.session_id)
if result is not None and result.ok:
logged_off.append(int(row.session_id))
else:
errors.append(result.message if result else f"logoff {row.session_id} failed")
if login_event is not None and logged_off:
mark_event_session_terminated(login_event, by_username=AUTO_DISCONNECT_BY)
if logged_off:
message = f"Auto logoff session(s) {', '.join(str(s) for s in logged_off)} on {workstation.hostname}"
ok = True
else:
ok = False
message = "; ".join(errors) if errors else "logoff failed"
return AutoDisconnectResult(
ok=ok,
message=message,
trigger_event_id=trigger_event.id,
workstation_host_id=workstation.id,
login_event_id=login_event.id if login_event else None,
session_ids=tuple(logged_off),
)
def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult | None:
if not event_has_rdg_flap(event):
return None
if _already_auto_disconnected(event):
return None
rdg_success = _rdg_pair_success_event(db, event)
if rdg_success is None:
return None
user = _event_user(event) or _event_user(rdg_success)
if not user:
return None
try:
workstation = resolve_client_workstation(db, rdg_success)
except ClientWorkstationNotFoundError as exc:
return AutoDisconnectResult(
ok=False,
message=str(exc),
trigger_event_id=event.id,
)
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
result = _disconnect_on_workstation(
db,
trigger_event=event,
workstation=workstation,
rdg_event=rdg_success,
user=user,
login_event=login_event,
)
_mark_auto_disconnect(event, result=result)
return result
def _auto_disconnect_direct_rdp_failed(db: Session, event: Event) -> AutoDisconnectResult | None:
if event.type != RDP_LOGIN_FAILED:
return None
if _already_auto_disconnected(event):
return None
host = event.host
if host is None:
return None
user = _event_login_user(event)
if not user:
return None
login_event = find_open_workstation_login(db, host_id=host.id, user=user)
if login_event is None:
return None
result = _disconnect_on_workstation(
db,
trigger_event=event,
workstation=host,
rdg_event=None,
user=user,
login_event=login_event,
)
_mark_auto_disconnect(event, result=result)
return result
def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDisconnectResult | None:
"""Log off stuck user session when auto-disconnect is enabled."""
if not get_effective_rdp_flap_settings(db).auto_disconnect:
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)
if result is not None:
if result.ok:
logger.info(
"auto rdp flap disconnect ok event_id=%s sessions=%s",
event.event_id,
result.session_ids,
)
else:
logger.warning(
"auto rdp flap disconnect failed event_id=%s: %s",
event.event_id,
result.message,
)
return result
result = _auto_disconnect_direct_rdp_failed(db, event)
if result is not None:
if result.ok:
logger.info(
"auto direct rdp disconnect ok event_id=%s sessions=%s",
event.event_id,
result.session_ids,
)
else:
logger.warning(
"auto direct rdp disconnect failed event_id=%s: %s",
event.event_id,
result.message,
)
return result
+33
View File
@@ -0,0 +1,33 @@
"""RDP / RDG flap auto-disconnect settings (ui_settings singleton)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
@dataclass(frozen=True)
class RdpFlapSettings:
auto_disconnect: bool
source: str = "db"
def get_effective_rdp_flap_settings(db: Session) -> RdpFlapSettings:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
return RdpFlapSettings(auto_disconnect=False, source="default")
return RdpFlapSettings(auto_disconnect=bool(row.auto_rdp_flap_disconnect), source="db")
def upsert_rdp_flap_settings(db: Session, *, auto_disconnect: bool) -> RdpFlapSettings:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
db.add(row)
row.auto_rdp_flap_disconnect = bool(auto_disconnect)
db.commit()
db.refresh(row)
return get_effective_rdp_flap_settings(db)
+115 -14
View File
@@ -20,6 +20,17 @@ SSH_MONITOR_VERSION_CMD = (
SSH_OUTPUT_MAX_LEN = 12_000 SSH_OUTPUT_MAX_LEN = 12_000
_AGENT_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:-SAC)?)", re.IGNORECASE) _AGENT_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:-SAC)?)", re.IGNORECASE)
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$") _IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
_SSH_TRANSIENT_ERROR_MARKERS = (
"no existing session",
"error reading ssh protocol banner",
"connection reset",
"connection lost",
"session not active",
"eof",
"socket is closed",
"connection timed out",
)
_SSH_CONNECT_ATTEMPTS = 2
class LinuxAdminNotConfiguredError(Exception): class LinuxAdminNotConfiguredError(Exception):
@@ -117,6 +128,58 @@ def _truncate_output(text: str) -> str:
return text[:SSH_OUTPUT_MAX_LEN] + "\n… (truncated)" return text[:SSH_OUTPUT_MAX_LEN] + "\n… (truncated)"
def _is_transient_ssh_error(exc: BaseException) -> bool:
text = str(exc).casefold()
return any(marker in text for marker in _SSH_TRANSIENT_ERROR_MARKERS)
def _ssh_connect_hint(exc: BaseException) -> str:
text = str(exc).casefold()
if "no existing session" in text:
return (
" Сервер закрыл SSH-сессию во время подключения или auth "
"(проверьте пароль admin, лимиты sshd MaxSessions/MaxStartups, "
"доступ SAC→хост по 22/tcp; на SAC не должен мешать ssh-agent)."
)
return ""
def _connect_ssh_client(
client,
*,
target: str,
user: str,
password: str,
connect_timeout_sec: int,
) -> None:
client.connect(
hostname=target,
username=user,
password=password,
timeout=connect_timeout_sec,
banner_timeout=connect_timeout_sec,
auth_timeout=connect_timeout_sec,
allow_agent=False,
look_for_keys=False,
compress=False,
)
def _close_ssh_client(client) -> None:
if client is None:
return
try:
transport = client.get_transport()
if transport is not None and transport.is_active():
transport.close()
except Exception:
pass
try:
client.close()
except Exception:
pass
def _shell_single_quote(value: str) -> str: def _shell_single_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'" return "'" + value.replace("'", "'\"'\"'") + "'"
@@ -127,15 +190,17 @@ def _remote_shell_command(
password: str, password: str,
*, *,
need_root: bool = False, need_root: bool = False,
login_shell: bool = True,
) -> str: ) -> str:
"""Build remote shell command. Sudo only when need_root and user is not root.""" """Build remote shell command. Sudo only when need_root and user is not root."""
safe_cmd = _shell_single_quote(remote_cmd) safe_cmd = _shell_single_quote(remote_cmd)
bash_flag = "lc" if login_shell else "c"
if user == "root" or not need_root: if user == "root" or not need_root:
return f"bash -lc {safe_cmd}" return f"bash -{bash_flag} {safe_cmd}"
safe_pw = _shell_single_quote(password) safe_pw = _shell_single_quote(password)
inner = f"printf '%s\\n' {safe_pw} | sudo -S -p '' bash -lc {safe_cmd}" inner = f"printf '%s\\n' {safe_pw} | sudo -S -p '' bash -{bash_flag} {safe_cmd}"
return f"bash -lc {_shell_single_quote(inner)}" return f"bash -{bash_flag} {_shell_single_quote(inner)}"
def run_ssh_command( def run_ssh_command(
@@ -147,6 +212,7 @@ def run_ssh_command(
connect_timeout_sec: int = 15, connect_timeout_sec: int = 15,
command_timeout_sec: int = 600, command_timeout_sec: int = 600,
need_root: bool = False, need_root: bool = False,
login_shell: bool = True,
) -> SshCommandResult: ) -> SshCommandResult:
target = target.strip() target = target.strip()
user = user.strip() user = user.strip()
@@ -158,23 +224,33 @@ def run_ssh_command(
except ImportError as exc: except ImportError as exc:
raise RuntimeError("paramiko is not installed on SAC server") from exc raise RuntimeError("paramiko is not installed on SAC server") from exc
shell_cmd = _remote_shell_command(
user,
remote_cmd,
password,
need_root=need_root,
login_shell=login_shell,
)
exit_code: int | None = None
out_text = ""
err_text = ""
for attempt in range(1, _SSH_CONNECT_ATTEMPTS + 1):
client = paramiko.SSHClient() client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
shell_cmd = _remote_shell_command(user, remote_cmd, password, need_root=need_root)
try: try:
client.connect( _connect_ssh_client(
hostname=target, client,
username=user, target=target,
user=user,
password=password, password=password,
timeout=connect_timeout_sec, connect_timeout_sec=connect_timeout_sec,
allow_agent=False,
look_for_keys=False,
) )
_stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec) _stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec)
exit_code = stdout.channel.recv_exit_status() exit_code = stdout.channel.recv_exit_status()
out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace")) out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace"))
err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace")) err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace"))
break
except paramiko.AuthenticationException: except paramiko.AuthenticationException:
return SshCommandResult( return SshCommandResult(
ok=False, ok=False,
@@ -188,13 +264,22 @@ def run_ssh_command(
target=target, target=target,
) )
except Exception as exc: except Exception as exc:
if attempt < _SSH_CONNECT_ATTEMPTS and _is_transient_ssh_error(exc):
continue
hint = _ssh_connect_hint(exc)
return SshCommandResult( return SshCommandResult(
ok=False, ok=False,
message=f"SSH error ({target}): {exc}", message=f"SSH error ({target}): {exc}{hint}",
target=target, target=target,
) )
finally: finally:
client.close() _close_ssh_client(client)
else:
return SshCommandResult(
ok=False,
message=f"SSH error ({target}): repeated transient connection failures",
target=target,
)
ok = exit_code == 0 ok = exit_code == 0
if ok: if ok:
@@ -215,6 +300,15 @@ def run_ssh_command(
) )
def _ssh_output_hostname(stdout: str) -> str:
"""Last line that looks like a hostname (MOTD/login banners may precede command output)."""
candidates = [ln.strip() for ln in stdout.splitlines() if ln.strip()]
for line in reversed(candidates):
if re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}$", line):
return line
return candidates[-1] if candidates else ""
def test_ssh_connection( def test_ssh_connection(
*, *,
target: str, target: str,
@@ -229,9 +323,12 @@ def test_ssh_connection(
remote_cmd="hostname", remote_cmd="hostname",
connect_timeout_sec=timeout_sec, connect_timeout_sec=timeout_sec,
command_timeout_sec=timeout_sec, command_timeout_sec=timeout_sec,
login_shell=False,
) )
if result.ok and result.stdout.strip(): if result.ok and result.stdout.strip():
host = result.stdout.strip().splitlines()[0] host = _ssh_output_hostname(result.stdout)
if not host:
return result
return SshCommandResult( return SshCommandResult(
ok=True, ok=True,
message=f"SSH OK, hostname={host}", message=f"SSH OK, hostname={host}",
@@ -264,6 +361,7 @@ def probe_ssh_monitor_version(
password=password, password=password,
remote_cmd=SSH_MONITOR_VERSION_CMD, remote_cmd=SSH_MONITOR_VERSION_CMD,
command_timeout_sec=30, command_timeout_sec=30,
login_shell=False,
) )
if result.ok and result.stdout.strip(): if result.ok and result.stdout.strip():
version = parse_ssh_monitor_version_text(result.stdout) version = parse_ssh_monitor_version_text(result.stdout)
@@ -339,6 +437,7 @@ def run_ssh_monitor_update(
password=password, password=password,
remote_cmd=check_cmd, remote_cmd=check_cmd,
command_timeout_sec=30, command_timeout_sec=30,
login_shell=False,
) )
if not probe.ok: if not probe.ok:
return SshCommandResult( return SshCommandResult(
@@ -363,6 +462,7 @@ def run_ssh_monitor_update(
remote_cmd=bootstrap_cmd, remote_cmd=bootstrap_cmd,
command_timeout_sec=900, command_timeout_sec=900,
need_root=True, need_root=True,
login_shell=False,
) )
bootstrapped = True bootstrapped = True
else: else:
@@ -373,6 +473,7 @@ def run_ssh_monitor_update(
remote_cmd=update_cmd, remote_cmd=update_cmd,
command_timeout_sec=900, command_timeout_sec=900,
need_root=True, need_root=True,
login_shell=False,
) )
if not updated.ok: if not updated.ok:
prefix = "Updater bootstrap failed" if bootstrapped else "SSH update failed" prefix = "Updater bootstrap failed" if bootstrapped else "SSH update failed"
+10 -5
View File
@@ -544,18 +544,23 @@ def format_event_mobile_push(event: Event) -> tuple[str, str]:
def _format_rdg_html(event: Event) -> str: def _format_rdg_html(event: Event) -> str:
from app.services.rdg_display import build_rdg_display
details = _details_dict(event) details = _details_dict(event)
display = build_rdg_display(event)
if event.type.endswith(".success"): if event.type.endswith(".success"):
header = "✅ RD Gateway: подключение" header = "✅ RDS через RD Gateway"
elif event.type.endswith(".disconnected"): elif event.type.endswith(".disconnected"):
header = "️ RD Gateway: отключение" header = "️ RDS отключение (RD Gateway)"
else: else:
header = "❌ RD Gateway: ошибка" header = "❌ RDS ошибка (RD Gateway)"
msg = f"<b>{header}</b>\n" msg = f"<b>{header}</b>\n"
if display is not None and display.access_path:
msg += _line("🔀", "Путь", html_escape(display.access_path))
msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username"))) msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username")))
msg += _line("🏢", "Хост", host_label(event.host)) msg += _line("🚪", "Шлюз RDG", host_label(event.host))
msg += _line("🌐", "Внешний IP", html_escape(_detail(details, "external_ip", "ip_address"))) msg += _line("🌐", "Внешний IP", html_escape(_detail(details, "external_ip", "ip_address")))
msg += _line("🏠", "Внутренний IP", html_escape(_detail(details, "internal_ip", default="-"))) msg += _line("🖥️", "Рабочий ПК", html_escape(_detail(details, "internal_ip", default="-")))
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))
+66 -45
View File
@@ -20,7 +20,8 @@ _CLIXML_PROGRESS_NOISE = frozenset(
) )
_CLIXML_MARKER = "#< CLIXML" _CLIXML_MARKER = "#< CLIXML"
_CYRILLIC_RE = re.compile(r"[\u0400-\u04FF]") _CYRILLIC_RE = re.compile(r"[\u0400-\u04FF]")
RDP_REMOTE_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging" RDP_REMOTE_STAGING_DIRNAME = "sac-rdp-staging"
RDP_LEGACY_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging"
RDP_BUNDLE_FILES = ( RDP_BUNDLE_FILES = (
"Login_Monitor.ps1", "Login_Monitor.ps1",
"Sac-Client.ps1", "Sac-Client.ps1",
@@ -165,12 +166,17 @@ def _winrm_failure_detail(stdout: str, stderr: str, exit_code: int) -> str:
stdout_plain = _clixml_to_plain(stdout) stdout_plain = _clixml_to_plain(stdout)
stderr_plain = _clixml_to_plain(stderr) stderr_plain = _clixml_to_plain(stderr)
for text in (stderr_plain, stdout_plain):
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("ERROR:"):
return stripped[6:].strip() or stripped
for candidate in (stderr_plain, stdout_plain): for candidate in (stderr_plain, stdout_plain):
if candidate and _CLIXML_MARKER not in candidate: if candidate and _CLIXML_MARKER not in candidate:
line = candidate.strip().splitlines()[0][:2000] lines = [line.strip() for line in candidate.splitlines() if line.strip()]
if line.startswith("ERROR:"): if lines:
return line[6:].strip() or line return lines[-1][:2000]
return line
if stdout_plain: if stdout_plain:
return stdout_plain[:2000] return stdout_plain[:2000]
@@ -380,52 +386,67 @@ def _custom_deploy_body(script_path: str) -> str:
) )
def _prepare_staging_body(staging_path: str) -> str: def _rdp_staging_setup_ps() -> str:
literal = _powershell_literal_path(staging_path) dirname = _powershell_literal_path(RDP_REMOTE_STAGING_DIRNAME)
legacy = _powershell_literal_path(RDP_LEGACY_STAGING)
return ( return (
f"$staging = '{literal}'\n" f"$staging = Join-Path $env:TEMP '{dirname}'\n"
"if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }\n" f"$legacyStaging = '{legacy}'\n"
"New-Item -ItemType Directory -Path $staging -Force | Out-Null\n" "if (Test-Path -LiteralPath $legacyStaging) {\n"
"Write-Output \"Staging ready: $staging\"\n" " Remove-Item -LiteralPath $legacyStaging -Recurse -Force -ErrorAction SilentlyContinue\n"
)
def _deploy_from_staging_body(staging_path: str) -> str:
literal = _powershell_literal_path(staging_path)
return (
f"$staging = '{literal}'\n"
"$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n"
"if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n"
"$logPath = Join-Path $env:ProgramData 'RDP-login-monitor\\Logs\\deploy.log'\n"
"Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n"
"powershell.exe -NoProfile -ExecutionPolicy Bypass -File $deploy -SourceShareRoot $staging\n"
"if (Test-Path -LiteralPath $logPath) {\n"
" Write-Output ''\n"
" Write-Output '--- deploy.log ---'\n"
" Get-Content -LiteralPath $logPath -Encoding UTF8 | Select-Object -Last 60\n"
"}\n" "}\n"
) )
def _prepare_staging_body() -> str:
return (
_rdp_staging_setup_ps()
+ "if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }\n"
+ "New-Item -ItemType Directory -Path $staging -Force | Out-Null\n"
+ "Write-Output \"Staging ready: $staging\"\n"
)
def _deploy_from_staging_body() -> str:
return (
_rdp_staging_setup_ps()
+ "$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n"
+ "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n"
+ "if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n"
+ "$logPath = Join-Path $env:ProgramData 'RDP-login-monitor\\Logs\\deploy.log'\n"
+ "Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n"
+ "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $deploy -SourceShareRoot $staging\n"
+ "if (Test-Path -LiteralPath $logPath) {\n"
+ " Write-Output ''\n"
+ " Write-Output '--- deploy.log ---'\n"
+ " Get-Content -LiteralPath $logPath -Encoding UTF8 | Select-Object -Last 60\n"
+ "}\n"
)
def _bundle_download_url(token: str) -> str: def _bundle_download_url(token: str) -> str:
base = get_settings().sac_public_url.rstrip("/") settings = get_settings()
base = (settings.sac_agent_bundle_base_url or settings.sac_public_url).rstrip("/")
return f"{base}/api/v1/agent/rdp-bundle/{token}" return f"{base}/api/v1/agent/rdp-bundle/{token}"
def _download_bundle_body(staging_path: str, bundle_url: str) -> str: def _download_bundle_body(bundle_url: str) -> str:
staging = _powershell_literal_path(staging_path)
url = _powershell_literal_path(bundle_url) url = _powershell_literal_path(bundle_url)
return ( return (
f"$staging = '{staging}'\n" _rdp_staging_setup_ps()
f"$url = '{url}'\n" + "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n"
"$zip = Join-Path $staging 'sac-rdp-bundle.zip'\n" + f"$url = '{url}'\n"
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n" + "$zip = Join-Path $env:TEMP ('sac-rdp-bundle-' + [Guid]::NewGuid().ToString() + '.zip')\n"
"Write-Output \"Downloading bundle: $url\"\n" + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n"
"Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n" + "Write-Output \"Downloading bundle: $url\"\n"
"Add-Type -AssemblyName System.IO.Compression.FileSystem\n" + "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n"
"[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging)\n" + "if (-not (Test-Path -LiteralPath $zip)) { throw 'Bundle download produced no file' }\n"
"Remove-Item -LiteralPath $zip -Force\n" + "$zipSize = (Get-Item -LiteralPath $zip).Length\n"
"Write-Output \"Bundle extracted to $staging\"\n" + "if ($zipSize -lt 100) { throw \"Bundle too small ($zipSize bytes)\" }\n"
+ "Add-Type -AssemblyName System.IO.Compression.FileSystem\n"
+ "[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging)\n"
+ "Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue\n"
+ "Write-Output \"Bundle extracted to $staging\"\n"
) )
@@ -486,7 +507,6 @@ def run_winrm_rdp_monitor_update(
target=target, target=target,
) )
staging = RDP_REMOTE_STAGING
zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES) zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES)
if not zip_bytes: if not zip_bytes:
return WinRmCmdResult( return WinRmCmdResult(
@@ -496,12 +516,13 @@ def run_winrm_rdp_monitor_update(
) )
bundle_token = register_rdp_bundle_zip(zip_bytes) bundle_token = register_rdp_bundle_zip(zip_bytes)
bundle_url = _bundle_download_url(bundle_token) bundle_url = _bundle_download_url(bundle_token)
staging_label = f"%TEMP%\\{RDP_REMOTE_STAGING_DIRNAME}"
prep = run_winrm_ps( prep = run_winrm_ps(
target=target, target=target,
user=user, user=user,
password=password, password=password,
script=_wrap_powershell_body(_prepare_staging_body(staging)), script=_wrap_powershell_body(_prepare_staging_body()),
timeout_sec=120, timeout_sec=120,
) )
if not prep.ok: if not prep.ok:
@@ -511,7 +532,7 @@ def run_winrm_rdp_monitor_update(
target=target, target=target,
user=user, user=user,
password=password, password=password,
script=_wrap_powershell_body(_download_bundle_body(staging, bundle_url)), script=_wrap_powershell_body(_download_bundle_body(bundle_url)),
timeout_sec=300, timeout_sec=300,
) )
if not download.ok: if not download.ok:
@@ -531,10 +552,10 @@ def run_winrm_rdp_monitor_update(
target=target, target=target,
user=user, user=user,
password=password, password=password,
script=_wrap_powershell_body(_deploy_from_staging_body(staging)), script=_wrap_powershell_body(_deploy_from_staging_body()),
timeout_sec=900, timeout_sec=900,
) )
header = f"SAC served bundle from git; client downloaded to {staging}" header = f"SAC served bundle from git; client staging {staging_label}"
stdout = "\n\n".join( stdout = "\n\n".join(
part.strip() part.strip()
for part in [header, prep.stdout, download.stdout, deploy.stdout] for part in [header, prep.stdout, download.stdout, deploy.stdout]
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.20.26" APP_VERSION = "0.4.14"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+42
View File
@@ -224,3 +224,45 @@ def test_api_agent_update_fallback_ssh(jwt_headers, client, db_session, monkeypa
job = wait_remote_job(client, host.id, jwt_headers) job = wait_remote_job(client, host.id, jwt_headers)
assert job["ok"] is True assert job["ok"] is True
assert job["product_version"] == "2.1.0-SAC" assert job["product_version"] == "2.1.0-SAC"
def test_clear_stale_running_remote_actions(db_session):
from app.services.host_remote_actions import clear_stale_running_remote_actions
host = Host(
hostname="stale-win",
os_family="windows",
product="rdp-login-monitor",
agent_update_state="running",
remote_action={"status": "running", "message": "Подключение…"},
)
db_session.add(host)
db_session.commit()
cleared = clear_stale_running_remote_actions(db_session, reason="test reset")
assert cleared == ["stale-win"]
db_session.refresh(host)
assert host.agent_update_state == "failed"
assert host.remote_action["status"] == "failed"
assert host.remote_action["ok"] is False
def test_cancel_host_remote_job_api(jwt_headers, client, db_session):
host = Host(
hostname="cancel-me",
os_family="windows",
product="rdp-login-monitor",
agent_update_state="running",
remote_action={"status": "running", "title": "Обновление через WinRM"},
)
db_session.add(host)
db_session.commit()
response = client.post(
f"/api/v1/hosts/{host.id}/actions/remote-job/cancel",
headers=jwt_headers,
)
assert response.status_code == 200
body = response.json()
assert body["active"] is False
assert body["status"] == "failed"
+56
View File
@@ -134,3 +134,59 @@ def test_normalize_report_body_adds_server_and_collapses_blanks():
user_lines = [ln for ln in body.split("\n") if ln.strip().startswith("👤")] user_lines = [ln for ln in body.split("\n") if ln.strip().startswith("👤")]
assert len(user_lines) == 2 assert len(user_lines) == 2
assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body
def test_normalize_ssh_active_users_mismatch_count_and_empty_body():
host = Host(hostname="srv", display_name="SSH", ipv4="10.0.0.1", product="ssh-monitor")
raw = "\n".join(
[
"📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА",
"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2):",
" (нет данных)",
]
)
body = normalize_report_body(raw, host, "ssh")
assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in body
assert "(нет данных)" in body
assert not any(ln.strip().startswith("👤") for ln in body.split("\n"))
def test_normalize_rdp_empty_active_users_placeholder():
host = Host(hostname="gw", display_name="K6A-DC3", ipv4="192.168.160.40", product="rdp-login-monitor")
raw = "\n".join(
[
"📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS",
"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0):",
" (нет активных пользователей / RDP-сессий)",
]
)
body = normalize_report_body(raw, host, "windows")
assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in body
assert "нет активных пользователей" in body
assert "👤 (нет активных" not in body
def test_normalize_daily_report_reconciles_stats_from_body():
from app.services.daily_report_format import normalize_daily_report_details
host = Host(hostname="srv", display_name="SSH", ipv4="10.0.0.2", product="ssh-monitor")
details = {
"report_body": "\n".join(
[
"📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА",
"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (9):",
" (нет данных)",
]
),
"stats": {
"successful_ssh": 1,
"active_sessions": 9,
"active_users": [],
"generated_by": "agent",
},
}
out = normalize_daily_report_details(details, host, "report.daily.ssh")
assert out is not None
assert out["stats"]["active_sessions"] == 0
assert out["stats"]["active_users"] == []
assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in out["report_body"]
@@ -55,6 +55,47 @@ def test_hidden_event_type_returns_404_on_detail(client, auth_headers, jwt_heade
assert listed.json()["total"] == 0 assert listed.json()["total"] == 0
def test_hidden_event_type_visible_on_host_detail_list(client, auth_headers, jwt_headers, db_session):
from app.models import Host
host = Host(hostname="inv-pc", os_family="windows", product="rdp-login-monitor")
db_session.add(host)
db_session.add(EventTypeVisibility(event_type="agent.inventory", show_in_events=False))
db_session.commit()
payload = {
**VALID_EVENT,
"type": "agent.inventory",
"category": "agent",
"title": "Inventory",
"summary": "hw",
"host": {"hostname": "inv-pc", "os_family": "windows"},
"details": {"inventory": {"memory_gb": 16}},
}
_ingest(client, auth_headers, payload)
global_list = client.get("/api/v1/events", headers=jwt_headers, params={"type": "agent.inventory"})
assert global_list.json()["total"] == 0
host_list = client.get(
"/api/v1/events",
headers=jwt_headers,
params={"host_id": host.id, "include_hidden": "true"},
)
assert host_list.status_code == 200
assert host_list.json()["total"] == 1
event_db_id = host_list.json()["items"][0]["id"]
detail = client.get(f"/api/v1/events/{event_db_id}", headers=jwt_headers)
assert detail.status_code == 200
assert detail.json()["type"] == "agent.inventory"
def test_include_hidden_requires_host_id(client, jwt_headers):
r = client.get("/api/v1/events", headers=jwt_headers, params={"include_hidden": "true"})
assert r.status_code == 400
def test_visibility_settings_api(client, jwt_headers, db_session): def test_visibility_settings_api(client, jwt_headers, db_session):
put = client.put( put = client.put(
"/api/v1/settings/notifications/severity-overrides", "/api/v1/settings/notifications/severity-overrides",
+54
View File
@@ -0,0 +1,54 @@
"""Tests for GET /api/v1/events filters."""
import uuid
VALID_RDG = {
"schema_version": "1.0",
"occurred_at": "2026-05-27T10:00:00+03:00",
"source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"},
"host": {"hostname": "gw-filter-test", "os_family": "windows"},
"category": "auth",
"severity": "info",
"title": "RDG",
"summary": "pytest",
}
def _ingest(client, auth_headers, *, event_type: str) -> str:
event_id = str(uuid.uuid4())
payload = {**VALID_RDG, "event_id": event_id, "type": event_type}
assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201
return event_id
def test_events_type_filter_prefix(client, auth_headers, jwt_headers):
success_id = _ingest(client, auth_headers, event_type="rdg.connection.success")
disconnected_id = _ingest(client, auth_headers, event_type="rdg.connection.disconnected")
other_id = _ingest(client, auth_headers, event_type="rdp.login.success")
listed = client.get(
"/api/v1/events",
headers=jwt_headers,
params={"type": "rdg.connection", "hostname": "gw-filter-test", "page_size": 50},
)
assert listed.status_code == 200
body = listed.json()
ids = {row["event_id"] for row in body["items"]}
assert success_id in ids
assert disconnected_id in ids
assert other_id not in ids
def test_events_type_filter_exact_still_works(client, auth_headers, jwt_headers):
success_id = _ingest(client, auth_headers, event_type="rdg.connection.success")
disconnected_id = _ingest(client, auth_headers, event_type="rdg.connection.disconnected")
listed = client.get(
"/api/v1/events",
headers=jwt_headers,
params={"type": "rdg.connection.success", "hostname": "gw-filter-test", "page_size": 50},
)
assert listed.status_code == 200
ids = {row["event_id"] for row in listed.json()["items"]}
assert success_id in ids
assert disconnected_id not in ids
+2 -2
View File
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants(): def test_version_constants():
assert APP_VERSION == "0.20.26" assert APP_VERSION == "0.3.6"
assert APP_NAME == "Security Alert Center" assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.20.26" assert APP_VERSION_LABEL == "Security Alert Center v.0.3.6"
+141
View File
@@ -0,0 +1,141 @@
"""Manual host add: validation, probe, deploy kick-off."""
from unittest.mock import patch
import pytest
from app.models import Host
from app.services.host_manual_add import (
ManualHostAddError,
prepare_manual_host_add,
validate_linux_target,
validate_windows_target,
)
from app.services.winrm_connect import WinRmTestResult
from tests.test_agent_update import wait_remote_job
def test_validate_windows_rejects_ip():
with pytest.raises(ManualHostAddError, match="не IP"):
validate_windows_target("10.10.36.9")
def test_validate_windows_accepts_hostname():
assert validate_windows_target("WORKSTATION-01") == "WORKSTATION-01"
assert validate_windows_target(r"B26\WORKSTATION-01") == "WORKSTATION-01"
def test_validate_linux_accepts_ip():
assert validate_linux_target("10.10.36.9") == "10.10.36.9"
def test_prepare_manual_host_windows(db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
mock_win.return_value = WinRmTestResult(
ok=True,
message="WinRM OK, hostname=WORKSTATION-01",
target="WORKSTATION-01",
hostname="WORKSTATION-01",
)
host = prepare_manual_host_add(
db_session,
platform="windows",
target="WORKSTATION-01",
)
assert host.hostname == "WORKSTATION-01"
assert host.product == "rdp-login-monitor"
assert host.os_family == "windows"
def test_api_manual_add_windows(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
mock_win.return_value = WinRmTestResult(
ok=True,
message="WinRM OK, hostname=NEW-PC",
target="NEW-PC",
hostname="NEW-PC",
)
with patch("app.services.agent_update.run_winrm_rdp_monitor_update") as mock_deploy:
from app.services.winrm_connect import WinRmCmdResult
mock_deploy.return_value = WinRmCmdResult(
ok=True,
message="deploy ok",
target="NEW-PC",
stdout="2.1.8-SAC",
)
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "windows", "target": "NEW-PC"},
headers=jwt_headers,
)
assert response.status_code == 202
body = response.json()
assert body["status"] == "running"
host_id = body["host_id"]
host = db_session.get(Host, host_id)
assert host is not None
assert host.hostname == "NEW-PC"
job = wait_remote_job(client, host_id, jwt_headers)
assert job["ok"] is True
def test_api_manual_add_rejects_windows_ip(jwt_headers, client):
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "windows", "target": "192.168.1.10"},
headers=jwt_headers,
)
assert response.status_code == 400
assert "не IP" in response.json()["detail"]
def test_api_manual_add_linux(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
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_ssh_connection") as mock_ssh:
from app.services.ssh_connect import SshCommandResult
mock_ssh.return_value = SshCommandResult(
ok=True,
message="SSH OK, hostname=linux-srv",
target="10.10.36.9",
stdout="linux-srv\n",
)
with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update:
mock_update.return_value = SshCommandResult(
ok=True,
message="updated",
target="10.10.36.9",
agent_version="2.1.5-SAC",
)
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "linux", "target": "10.10.36.9"},
headers=jwt_headers,
)
assert response.status_code == 202
host_id = response.json()["host_id"]
host = db_session.get(Host, host_id)
assert host.hostname == "linux-srv"
assert host.ipv4 == "10.10.36.9"
job = wait_remote_job(client, host_id, jwt_headers, timeout=8.0)
assert job["ok"] is True
+149
View File
@@ -0,0 +1,149 @@
"""Tests for host session list/parse helpers."""
from types import SimpleNamespace
from app.services.host_sessions import (
HostSessionRow,
_event_login_user,
event_session_terminated,
event_supports_session_terminate,
filter_logind_session_rows,
mark_event_session_terminated,
parse_loginctl_sessions,
parse_loginctl_sessions_json,
parse_qwinsta_sessions,
terminate_session_for_event,
)
from app.services.linux_admin_settings import LinuxAdminConfig
from app.services.win_admin_settings import WinAdminConfig
from app.services.winrm_connect import WinRmCmdResult
def test_parse_loginctl_sessions():
stdout = "c1 1000 alice seat0 pts/0 active -\n 2 1001 bob - tty2 active -\n"
rows = parse_loginctl_sessions(stdout)
assert len(rows) == 2
assert rows[0].session_id == "c1"
assert rows[0].user == "alice"
assert rows[0].tty == "pts/0"
def test_parse_loginctl_sessions_prefers_pts_over_ephemeral_duplicate():
stdout = """21166 1000 papatramp - pts/0 active no -
21169 1000 papatramp - - active no -
"""
rows = parse_loginctl_sessions(stdout)
assert len(rows) == 1
assert rows[0].session_id == "21166"
assert rows[0].tty == "pts/0"
def test_parse_loginctl_sessions_json():
stdout = """[
{"session":"21166","uid":1000,"user":"papatramp","seat":null,"tty":"pts/0","state":"active","idle":false,"since":null},
{"session":"21169","uid":1000,"user":"papatramp","seat":null,"tty":null,"state":"active","idle":false,"since":null}
]"""
rows = parse_loginctl_sessions_json(stdout)
assert len(rows) == 1
assert rows[0].session_id == "21166"
assert rows[0].tty == "pts/0"
def test_filter_logind_session_rows_keeps_distinct_users():
from app.services.host_sessions import HostSessionRow
rows = filter_logind_session_rows(
[
HostSessionRow(session_id="1", user="alice", tty="pts/0", state="active"),
HostSessionRow(session_id="2", user="bob", tty=None, state="active"),
]
)
assert len(rows) == 2
def test_parse_qwinsta_sessions_filters_user():
stdout = """SESSIONNAME USERNAME ID STATE
console Administrator 1 Active
rdp-tcp#0 B26\\alice 2 Active
"""
all_rows = parse_qwinsta_sessions(stdout)
assert len(all_rows) == 2
filtered = parse_qwinsta_sessions(stdout, filter_user="alice")
assert len(filtered) == 1
assert filtered[0].session_id == "2"
def test_event_supports_session_terminate_types():
class HostStub:
os_family = "linux"
product = "ssh-monitor"
class EventStub:
def __init__(self, event_type: str):
self.type = event_type
self.host = HostStub()
assert event_supports_session_terminate(EventStub("ssh.login.success")) is True
def test_event_login_user_without_orm_actor_user_attr():
event = SimpleNamespace(
type="rdp.login.success",
details={"user": "papatramp"},
)
assert _event_login_user(event) == "papatramp"
def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch):
host = SimpleNamespace(
os_family="windows",
product="rdp-login-monitor",
hostname="srv01",
ipv4="10.0.0.1",
display_name=None,
inventory={},
)
event = SimpleNamespace(
type="rdp.login.success",
details={"user": "papatramp"},
host=host,
)
rows = [HostSessionRow(session_id="2", user="B26\\papatramp", state="Active")]
def fake_list(_host, _cfg):
return rows, WinRmCmdResult(ok=True, message="ok", target="srv01")
def fake_term(_host, _cfg, sid):
assert sid == "2"
return WinRmCmdResult(ok=True, message="logged off", target="srv01")
monkeypatch.setattr(
"app.services.host_sessions.list_windows_sessions",
fake_list,
)
monkeypatch.setattr(
"app.services.host_sessions.terminate_windows_session",
fake_term,
)
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
def test_event_session_terminated_flag():
event = SimpleNamespace(
type="rdp.login.success",
details={"session_terminated_at": "2026-06-24T10:00:00+00:00"},
)
assert event_session_terminated(event) is True
fresh = SimpleNamespace(type="rdp.login.success", details={"user": "alice"})
assert event_session_terminated(fresh) is False
mark_event_session_terminated(fresh, by_username="admin")
assert event_session_terminated(fresh) is True
assert fresh.details["session_terminated_by"] == "admin"
+154
View File
@@ -82,6 +82,43 @@ def test_scan_does_not_duplicate_open_problem(db_session, scan_settings):
assert len(problems) == 1 assert len(problems) == 1
def test_scan_does_not_duplicate_after_correlation_window(db_session, scan_settings, monkeypatch):
"""host_silence — одна open-проблема на хост, даже если прошло > correlation window."""
monkeypatch.setenv("SAC_PROBLEM_CORRELATION_WINDOW_MINUTES", "60")
get_settings.cache_clear()
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
t0 = datetime.now(timezone.utc)
first = run_host_silence_scan(db_session, now=t0)
assert first[0].created is True
first[0].problem.last_seen_at = t0 - timedelta(hours=2)
db_session.flush()
later = run_host_silence_scan(db_session, now=t0 + timedelta(hours=3))
assert len(later) == 1
assert later[0].created is False
assert later[0].problem.id == first[0].problem.id
problems = db_session.scalars(
select(Problem).where(
Problem.host_id == hb.host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status == "open",
)
).all()
assert len(problems) == 1
def test_scan_skips_host_without_heartbeat(db_session, scan_settings): def test_scan_skips_host_without_heartbeat(db_session, scan_settings):
_ingest( _ingest(
db_session, db_session,
@@ -174,6 +211,123 @@ def test_scan_reopens_after_manual_cooldown_expires(db_session, scan_settings, m
assert later[0].problem.resolved_by is None assert later[0].problem.resolved_by is None
def test_scan_does_not_duplicate_acknowledged_problem(db_session, scan_settings):
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
now = datetime.now(timezone.utc)
first = run_host_silence_scan(db_session, now=now)
assert first[0].created is True
first[0].problem.status = "acknowledged"
db_session.flush()
second = run_host_silence_scan(db_session, now=now + timedelta(minutes=5))
assert len(second) == 1
assert second[0].created is False
assert second[0].problem.id == first[0].problem.id
problems = db_session.scalars(
select(Problem).where(
Problem.host_id == hb.host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status.in_(("open", "acknowledged")),
)
).all()
assert len(problems) == 1
def test_scan_dedupes_by_hostname_across_duplicate_hosts(db_session, scan_settings):
from app.models import Host
now = datetime.now(timezone.utc)
host_a = Host(
hostname="COMM-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.162.33",
)
host_b = Host(
hostname="COMM-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.162.33",
)
db_session.add_all([host_a, host_b])
db_session.flush()
hb = _ingest(
db_session,
host={"hostname": host_a.hostname, "os_family": "windows", "ipv4": host_a.ipv4},
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.host_id = host_a.id
hb.received_at = now - timedelta(hours=2)
db_session.flush()
first = run_host_silence_scan(db_session, now=now)
assert len(first) == 1
assert first[0].created is True
hb_b = _ingest(
db_session,
event_id=str(uuid.uuid4()),
host={"hostname": host_b.hostname, "os_family": "windows", "ipv4": host_b.ipv4},
source={"product": "rdp-login-monitor", "product_version": "2.0.0-SAC"},
type="agent.heartbeat",
category="agent",
severity="info",
title="hb2",
summary="heartbeat",
)
hb_b.host_id = host_b.id
hb_b.received_at = now - timedelta(hours=2)
db_session.flush()
second = run_host_silence_scan(db_session, now=now + timedelta(minutes=5))
assert len(second) == 2
assert all(not item.created for item in second)
assert {item.problem.id for item in second} == {first[0].problem.id}
active = db_session.scalars(
select(Problem).where(
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status.in_(("open", "acknowledged")),
)
).all()
assert len(active) == 1
def test_scan_skipped_when_advisory_lock_not_acquired(db_session, scan_settings, monkeypatch):
monkeypatch.setattr(
"app.services.host_silence_scan._host_silence_scan_lock",
lambda _db: False,
)
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
assert run_host_silence_scan(db_session) == []
def test_scan_notifies_only_on_create(db_session, scan_settings): def test_scan_notifies_only_on_create(db_session, scan_settings):
hb = _ingest( hb = _ingest(
db_session, db_session,
+1 -1
View File
@@ -126,7 +126,7 @@ def test_ingest_daily_report_calls_notify_daily_report(client, auth_headers):
"summary": "stats", "summary": "stats",
"details": {"generated_by": "agent", "report_body": "line1"}, "details": {"generated_by": "agent", "report_body": "line1"},
} }
with patch.object(events_api, "notify_daily_report") as mock_daily: with patch.object(events_api, "schedule_notify_daily_report") as mock_daily:
with patch.object(events_api, "notify_event") as mock_event: with patch.object(events_api, "notify_event") as mock_event:
r = client.post("/api/v1/events", json=payload, headers=auth_headers) r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201 assert r.status_code == 201
@@ -0,0 +1,88 @@
"""Login security whitelist and unblock."""
from datetime import datetime, timezone
from sqlalchemy import delete
from app.models.login_attempt import LoginAttempt
from app.services.login_security_settings import (
clear_web_login_block,
get_effective_login_security_config,
is_ip_login_whitelisted,
list_web_login_blocks,
parse_ip_whitelist_text,
upsert_login_security_settings,
)
def test_parse_ip_whitelist_text():
assert parse_ip_whitelist_text("192.168.160.3\n192.168.160.4") == [
"192.168.160.3",
"192.168.160.4",
]
assert parse_ip_whitelist_text("192.168.160.3, 10.0.0.1") == [
"192.168.160.3",
"10.0.0.1",
]
assert parse_ip_whitelist_text("not-an-ip\n192.168.1.1") == ["192.168.1.1"]
def test_whitelisted_ip_not_blocked(client, db_session, monkeypatch):
monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "2")
monkeypatch.setenv("SAC_LOGIN_ALERT_TELEGRAM", "false")
monkeypatch.setattr(
"app.services.login_rate_limit.client_ip_from_request",
lambda _request: "192.168.160.3",
)
from app.config import get_settings
get_settings.cache_clear()
db_session.execute(delete(LoginAttempt))
db_session.commit()
upsert_login_security_settings(
db_session,
ip_whitelist_text="192.168.160.3",
sync_fail2ban=False,
)
for _ in range(5):
r = client.post(
"/api/v1/auth/login",
json={"username": "test-admin", "password": "wrong"},
)
assert r.status_code == 401
get_settings.cache_clear()
def test_list_web_blocks_and_clear(db_session, monkeypatch):
monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "2")
monkeypatch.setenv("SAC_LOGIN_FAILURE_WINDOW_MINUTES", "15")
from app.config import get_settings
get_settings.cache_clear()
now = datetime.now(timezone.utc)
db_session.add(
LoginAttempt(ip_address="203.0.113.50", username="bad", success=False, created_at=now)
)
db_session.add(
LoginAttempt(ip_address="203.0.113.50", username="bad", success=False, created_at=now)
)
db_session.commit()
blocks = list_web_login_blocks(db_session)
assert any(b.ip_address == "203.0.113.50" for b in blocks)
deleted = clear_web_login_block(db_session, "203.0.113.50")
assert deleted >= 2
assert not list_web_login_blocks(db_session)
get_settings.cache_clear()
def test_is_ip_login_whitelisted():
wl = ("192.168.160.3",)
assert is_ip_login_whitelisted("192.168.160.3", wl)
assert not is_ip_login_whitelisted("192.168.160.4", wl)
+38
View File
@@ -124,6 +124,44 @@ def test_notify_event_skipped_by_cooldown():
mock_tg.notify_event.assert_not_called() mock_tg.notify_event.assert_not_called()
def test_notify_event_skips_hidden_event_type(db_session):
from app.models.event_type_visibility import EventTypeVisibility
db_session.add(EventTypeVisibility(event_type="agent.inventory", show_in_events=False))
db_session.commit()
event = Event(
event_id="00000000-0000-4000-8000-000000000701",
host_id=1,
occurred_at=datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc),
category="agent",
type="agent.inventory",
severity="warning",
title="Hardware changed",
summary="memory",
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=True,
use_email=True,
use_mobile=True,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
with patch.object(notify_dispatch, "webhook_notify") as mock_wh:
with patch.object(notify_dispatch, "email_notify") as mock_em:
with patch.object(notify_dispatch, "mobile_notify") as mock_mob:
notify_dispatch.notify_event(event, db=db_session)
mock_tg.notify_event.assert_not_called()
mock_wh.notify_event.assert_not_called()
mock_em.notify_event.assert_not_called()
mock_mob.notify_event.assert_not_called()
def test_notify_auth_login_bypasses_min_severity(): def test_notify_auth_login_bypasses_min_severity():
event = Event( event = Event(
event_id="00000000-0000-4000-8000-000000000601", event_id="00000000-0000-4000-8000-000000000601",
+136
View File
@@ -0,0 +1,136 @@
"""Tests for RDG event display (access path, title/summary)."""
from datetime import datetime, timezone
import pytest
from app.models import Event, Host
from app.services.event_summary import event_to_summary
from app.services.rdg_display import (
ACCESS_PATH_DIRECT,
ACCESS_PATH_HAPROXY,
build_rdg_display,
classify_rdg_access_path,
event_supports_rdg_client_qwinsta,
)
def _rdg_event(
db_session,
*,
gw: Host,
event_type: str = "rdg.connection.success",
external_ip: str = "10.0.0.5",
internal_ip: str = "192.168.160.3",
win_id: int = 302,
) -> Event:
event = Event(
event_id=f"ev-rdg-{win_id}",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type=event_type,
severity="info",
title="RD Gateway event 302",
summary="",
payload={},
details={
"user": r"B26\papatramp",
"external_ip": external_ip,
"internal_ip": internal_ip,
"event_id_windows": win_id,
},
)
db_session.add(event)
db_session.commit()
db_session.refresh(event)
return event
def test_classify_rdg_access_path_direct(monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50")
from app.config import get_settings
get_settings.cache_clear()
assert classify_rdg_access_path("10.0.0.5") == ACCESS_PATH_DIRECT
def test_classify_rdg_access_path_haproxy(monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50,10.0.0.100")
from app.config import get_settings
get_settings.cache_clear()
assert classify_rdg_access_path("10.0.0.100") == ACCESS_PATH_HAPROXY
def test_build_rdg_display_title_and_path(db_session, monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "")
from app.config import get_settings
get_settings.cache_clear()
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
ws = Host(hostname="WS-PC", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.3")
db_session.add_all([gw, ws])
db_session.commit()
event = _rdg_event(db_session, gw=gw)
display = build_rdg_display(event, db_session)
assert display is not None
assert ACCESS_PATH_DIRECT in display.title
assert "WS-PC" in display.title
assert "192.168.160.3" in display.title
assert display.access_path == ACCESS_PATH_DIRECT
assert display.qwinsta_enabled is True
assert "papatramp" in display.summary
assert "шлюз K6A-DC3" in display.summary
assert "event 302" in display.title
def test_event_to_summary_enriches_rdg(db_session, monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "10.0.0.5")
from app.config import get_settings
get_settings.cache_clear()
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw, external_ip="10.0.0.5")
summary = event_to_summary(event, db_session)
assert summary.rdg_access_path == ACCESS_PATH_HAPROXY
assert summary.rdg_qwinsta_enabled is True
assert "RDS подключение" in summary.title
assert ACCESS_PATH_HAPROXY in summary.title
def test_event_supports_rdg_client_qwinsta_without_flap(db_session):
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw)
assert event_supports_rdg_client_qwinsta(event, db_session) is True
def test_event_supports_rdg_client_qwinsta_not_on_disconnect(db_session):
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw, event_type="rdg.connection.disconnected", win_id=303)
assert event_supports_rdg_client_qwinsta(event, db_session) is False
@pytest.mark.parametrize(
"internal_ip",
["", None],
)
def test_event_supports_rdg_client_qwinsta_requires_internal_ip(db_session, internal_ip):
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw, internal_ip=internal_ip or "")
if internal_ip is None:
event.details = {k: v for k, v in event.details.items() if k != "internal_ip"}
assert event_supports_rdg_client_qwinsta(event, db_session) is False
+136 -3
View File
@@ -15,6 +15,7 @@ from app.services.rdg_session_flap import (
event_has_rdg_flap, event_has_rdg_flap,
find_rdg_success_before_end, find_rdg_success_before_end,
resolve_rdg_flap_summary, resolve_rdg_flap_summary,
resolve_rdg_qwinsta_enabled,
) )
from app.services.event_summary import event_to_summary from app.services.event_summary import event_to_summary
from tests.test_ingest import VALID_EVENT from tests.test_ingest import VALID_EVENT
@@ -193,14 +194,146 @@ def test_resolve_rdg_flap_summary_for_302_and_303(db_session, rdg_settings):
assert end_flap is True assert end_flap is True
assert end_pair == start.id assert end_pair == start.id
assert end_qwinsta == end.id assert end_qwinsta == start.id
assert start_flap is True assert start_flap is True
assert start_pair == end.id assert start_pair == end.id
assert start_qwinsta == end.id assert start_qwinsta == start.id
end_summary = event_to_summary(end, db_session) end_summary = event_to_summary(end, db_session)
start_summary = event_to_summary(start, db_session) start_summary = event_to_summary(start, db_session)
assert end_summary.rdg_flap is True assert end_summary.rdg_flap is True
assert start_summary.rdg_flap is True assert start_summary.rdg_flap is True
assert start_summary.rdg_flap_qwinsta_event_id == end.id assert start_summary.rdg_flap_qwinsta_event_id == start.id
assert start_summary.rdg_qwinsta_enabled is True
assert end_summary.rdg_qwinsta_enabled is False
def test_rdg_qwinsta_disabled_after_normal_session_end(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\normal.user"
details = {"user": user, "internal_ip": "192.168.163.49"}
start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(minutes=20),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
assert resolve_rdg_qwinsta_enabled(db_session, start) is False
assert resolve_rdg_qwinsta_enabled(db_session, end) is False
start_summary = event_to_summary(start, db_session)
end_summary = event_to_summary(end, db_session)
assert start_summary.rdg_qwinsta_enabled is False
assert end_summary.rdg_qwinsta_enabled is False
def test_rdg_qwinsta_enabled_while_session_open(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
details = {"user": r"B26\active.user", "internal_ip": "192.168.163.50"}
start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
assert resolve_rdg_qwinsta_enabled(db_session, start) is True
assert event_to_summary(start, db_session).rdg_qwinsta_enabled is True
def test_rdg_qwinsta_disabled_on_flap_302_after_later_success(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = r"B26\m.semenova"
details = {"user": user, "internal_ip": "192.168.164.45"}
flap_start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="warning",
title="302",
summary="302",
details=details,
)
flap_end = _ingest(
db_session,
t0 + timedelta(seconds=5),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
evaluate_rdg_session_flap(db_session, flap_end)
db_session.flush()
assert resolve_rdg_qwinsta_enabled(db_session, flap_start) is True
_ingest(
db_session,
t0 + timedelta(minutes=2, seconds=44),
type="rdg.connection.success",
category="auth",
severity="warning",
title="302",
summary="302",
details=details,
)
assert resolve_rdg_qwinsta_enabled(db_session, flap_start) is False
assert event_to_summary(flap_start, db_session).rdg_qwinsta_enabled is False
def test_rdg_qwinsta_stays_enabled_when_unrelated_303_without_internal_ip(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\khodasevich"
details_302 = {"user": user, "internal_ip": "192.168.160.209"}
details_303_other = {"user": user}
start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="warning",
title="302",
summary="302",
details=details_302,
)
_ingest(
db_session,
t0 + timedelta(minutes=5),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details_303_other,
)
assert resolve_rdg_qwinsta_enabled(db_session, start) is True
assert event_to_summary(start, db_session).rdg_qwinsta_enabled is True
+121 -9
View File
@@ -9,22 +9,21 @@ from app.models import Event, Host
from app.services.winrm_connect import WinRmCmdResult from app.services.winrm_connect import WinRmCmdResult
def _flap_event(db_session, *, gw: Host, ws: Host) -> Event: def _rdg_success_event(db_session, *, gw: Host, ws: Host) -> Event:
event = Event( event = Event(
event_id="ev-flap-1", event_id="ev-rdg-302",
host_id=gw.id, host_id=gw.id,
occurred_at=datetime.now(timezone.utc), occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc), received_at=datetime.now(timezone.utc),
category="auth", category="auth",
type="rdg.connection.disconnected", type="rdg.connection.success",
severity="info", severity="info",
title="RD Gateway event 303", title="RD Gateway event 302",
summary="", summary="",
payload={}, payload={},
details={ details={
"user": r"B26\papatramp", "user": r"B26\papatramp",
"internal_ip": ws.ipv4, "internal_ip": ws.ipv4,
"rdg_flap": True,
}, },
) )
db_session.add(event) db_session.add(event)
@@ -50,7 +49,7 @@ def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monke
db_session.add_all([ws, gw]) db_session.add_all([ws, gw])
db_session.commit() db_session.commit()
ws_id = ws.id ws_id = ws.id
event = _flap_event(db_session, gw=gw, ws=ws) event = _rdg_success_event(db_session, gw=gw, ws=ws)
qwinsta_out = " SESSIONNAME USERNAME ID STATE\r\n rdp-tcp#0 B26\\papatramp 2 Active\r\n" qwinsta_out = " SESSIONNAME USERNAME ID STATE\r\n rdp-tcp#0 B26\\papatramp 2 Active\r\n"
@@ -96,12 +95,12 @@ def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatc
occurred_at=datetime.now(timezone.utc), occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc), received_at=datetime.now(timezone.utc),
category="auth", category="auth",
type="rdg.connection.disconnected", type="rdg.connection.success",
severity="info", severity="info",
title="303", title="302",
summary="", summary="",
payload={}, payload={},
details={"user": r"B26\user", "internal_ip": "192.168.160.999", "rdg_flap": True}, details={"user": r"B26\user", "internal_ip": "192.168.160.999"},
) )
db_session.add(event) db_session.add(event)
db_session.commit() db_session.commit()
@@ -109,3 +108,116 @@ def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatc
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers) response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
assert response.status_code == 404 assert response.status_code == 404
assert "not found" in response.json()["detail"].lower() assert "not found" in response.json()["detail"].lower()
def test_qwinsta_without_rdg_flap(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
ws = Host(
hostname="Andrisonova-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.113",
)
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add_all([ws, gw])
db_session.commit()
event = Event(
event_id="ev-rdg-plain",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.success",
severity="info",
title="302",
summary="",
payload={},
details={"user": r"B26\papatramp", "internal_ip": ws.ipv4},
)
db_session.add(event)
db_session.commit()
with patch("app.services.rdg_winrm_actions.run_winrm_on_host_targets") as mock_run:
mock_run.return_value = (
WinRmCmdResult(ok=True, message="OK", target="Andrisonova-PC", stdout="ok", exit_code=0),
["Andrisonova-PC=OK"],
)
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
assert response.status_code == 200
mock_run.assert_called_once()
def test_qwinsta_rejects_rdg_without_internal_ip(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = Event(
event_id="ev-rdg-no-ip",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.success",
severity="info",
title="302",
summary="",
payload={},
details={"user": r"B26\user"},
)
db_session.add(event)
db_session.commit()
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
assert response.status_code == 400
assert "internal_ip" in response.json()["detail"].lower()
def test_qwinsta_rejects_rdg_303_disconnect(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
ws = Host(
hostname="Andrisonova-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.113",
)
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add_all([ws, gw])
db_session.commit()
event = Event(
event_id="ev-rdg-303",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.disconnected",
severity="info",
title="303",
summary="",
payload={},
details={"user": r"B26\papatramp", "internal_ip": ws.ipv4},
)
db_session.add(event)
db_session.commit()
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
assert response.status_code == 400
@@ -0,0 +1,268 @@
"""Tests for RDG 303 → workstation rdp.login.success correlation."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from app.config import get_settings
from app.models import Host
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.rdg_workstation_session import (
SESSION_CLOSED_BY_RDG_AT_KEY,
SESSION_CLOSED_BY_RDG_EVENT_ID_KEY,
close_workstation_session_for_rdg_end,
find_rdg_end_after_workstation_login,
resolve_workstation_login_closed,
)
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
@pytest.fixture
def rdg_settings(monkeypatch):
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1")
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10")
monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture
def rdg_hosts(db_session):
ws = Host(
hostname="TSA-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.163.100",
)
gw = Host(
hostname="K6A-DC3",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.40",
)
db_session.add_all([ws, gw])
db_session.commit()
return ws, gw
def test_rdg_end_marks_workstation_login_closed_on_ingest(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
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.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": user},
)
end = _ingest(
db_session,
t0 + timedelta(hours=2),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="RD Gateway event 303",
summary="303",
details={"user": user, "internal_ip": internal_ip},
)
assert login.details[SESSION_CLOSED_BY_RDG_AT_KEY] == end.occurred_at.isoformat()
assert login.details[SESSION_CLOSED_BY_RDG_EVENT_ID_KEY] == end.id
assert event_session_terminated(login, db=db_session) is True
summary = event_to_summary(login, db_session)
assert summary.session_terminated is True
def test_rdg_flap_does_not_close_workstation_login(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
internal_ip = ws.ipv4
details = {"user": user, "internal_ip": internal_ip}
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.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": user},
)
_ingest(
db_session,
t0,
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
_ingest(
db_session,
t0 + timedelta(seconds=4),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
db_session.refresh(login)
assert SESSION_CLOSED_BY_RDG_AT_KEY not in (login.details or {})
assert event_session_terminated(login, db=db_session) is False
assert event_to_summary(login, db_session).session_terminated is False
def test_runtime_resolve_for_historical_login_without_flag(db_session, rdg_settings, rdg_hosts):
ws, gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
internal_ip = ws.ipv4
from app.models import Event
login = Event(
event_id=str(uuid.uuid4()),
host_id=ws.id,
occurred_at=t0 + timedelta(seconds=1),
received_at=t0,
category="auth",
type="rdp.login.success",
severity="info",
title="RDP login",
summary="1149",
payload={},
details={"user": "TSA"},
)
end = Event(
event_id=str(uuid.uuid4()),
host_id=gw.id,
occurred_at=t0 + timedelta(hours=2),
received_at=t0,
category="auth",
type="rdg.connection.disconnected",
severity="info",
title="303",
summary="303",
payload={},
details={"user": user, "internal_ip": internal_ip},
)
db_session.add_all([login, end])
db_session.commit()
assert resolve_workstation_login_closed(db_session, login) is True
assert find_rdg_end_after_workstation_login(db_session, login) is not None
assert event_to_summary(login, db_session).session_terminated is True
def test_user_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
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.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": r"B26\Alice"},
)
end = _ingest(
db_session,
t0 + timedelta(hours=1),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details={"user": r"B26\Bob", "internal_ip": internal_ip},
)
db_session.refresh(login)
assert close_workstation_session_for_rdg_end(db_session, end) is None
assert event_session_terminated(login, db=db_session) is False
def test_ip_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
login = _ingest(
db_session,
t0,
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": user},
)
end = _ingest(
db_session,
t0 + timedelta(hours=1),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details={"user": user, "internal_ip": "192.168.163.200"},
)
db_session.refresh(login)
assert close_workstation_session_for_rdg_end(db_session, end) is None
assert event_session_terminated(login, db=db_session) is False
@@ -0,0 +1,242 @@
"""Tests for RDP flap auto-disconnect setting and service."""
import uuid
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from app.config import get_settings
from app.models import Host
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
from app.services.ingest import ingest_event
from app.services.problems import maybe_create_problem
from app.services.rdp_flap_auto_disconnect import maybe_auto_disconnect_stuck_rdp_session
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings, upsert_rdp_flap_settings
from app.services.winrm_connect import WinRmCmdResult
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
@pytest.fixture
def rdg_settings(monkeypatch):
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1")
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10")
monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30")
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "secret")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture
def rdg_hosts(db_session):
ws = Host(
hostname="TSA-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.163.100",
)
gw = Host(
hostname="K6A-DC3",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.40",
)
db_session.add_all([ws, gw])
db_session.commit()
return ws, gw
def test_rdp_flap_settings_default_disabled(db_session):
cfg = get_effective_rdp_flap_settings(db_session)
assert cfg.auto_disconnect is False
assert cfg.source == "default"
def test_rdp_flap_settings_upsert(db_session):
upsert_rdp_flap_settings(db_session, auto_disconnect=True)
cfg = get_effective_rdp_flap_settings(db_session)
assert cfg.auto_disconnect is True
row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID)
assert row is not None
assert row.auto_rdp_flap_disconnect is True
def test_auto_disconnect_skipped_when_disabled(db_session, rdg_settings, rdg_hosts, monkeypatch):
ws, gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
details = {"user": user, "internal_ip": ws.ipv4}
gw_payload = {
"host": {"hostname": gw.hostname, "os_family": "windows", "ipv4": gw.ipv4},
"source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
}
_ingest(
db_session,
t0,
**gw_payload,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(seconds=4),
**gw_payload,
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
maybe_create_problem(db_session, end)
mock_logoff = MagicMock()
monkeypatch.setattr("app.services.rdp_flap_auto_disconnect.execute_logoff_via_winrm", mock_logoff)
result = maybe_auto_disconnect_stuck_rdp_session(db_session, end)
assert result is None
mock_logoff.assert_not_called()
def test_auto_disconnect_rdg_flap_calls_logoff(db_session, rdg_settings, rdg_hosts, monkeypatch):
ws, gw = rdg_hosts
upsert_rdp_flap_settings(db_session, auto_disconnect=True)
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
details = {"user": user, "internal_ip": ws.ipv4}
gw_payload = {
"host": {"hostname": gw.hostname, "os_family": "windows", "ipv4": gw.ipv4},
"source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
}
_ingest(
db_session,
t0,
**gw_payload,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
_ingest(
db_session,
t0 + timedelta(seconds=1),
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="login",
summary="login",
details={"user": user},
)
end = _ingest(
db_session,
t0 + timedelta(seconds=4),
**gw_payload,
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
maybe_create_problem(db_session, end)
qwinsta_stdout = "SESSIONNAME USERNAME ID STATE\n rdp-tcp#0 B26\\TSA 5 Active\n"
monkeypatch.setattr(
"app.services.rdp_flap_auto_disconnect.list_windows_sessions",
lambda host, cfg: (
[],
WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or "", stdout=qwinsta_stdout),
),
)
mock_logoff = MagicMock(return_value=SimpleNamespace(status="completed", result_stderr=None, result_stdout="ok"))
monkeypatch.setattr("app.services.rdp_flap_auto_disconnect.execute_logoff_via_winrm", mock_logoff)
result = maybe_auto_disconnect_stuck_rdp_session(db_session, end)
assert result is not None
assert result.ok is True
assert result.session_ids == (5,)
mock_logoff.assert_called_once()
assert end.details["rdp_flap_auto_disconnect"]["ok"] is True
def test_auto_disconnect_direct_rdp_failed(db_session, rdg_settings, rdg_hosts, monkeypatch):
ws, _gw = rdg_hosts
upsert_rdp_flap_settings(db_session, auto_disconnect=True)
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
ws_payload = {
"host": {"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4},
"source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
}
_ingest(
db_session,
t0,
**ws_payload,
type="rdp.login.success",
category="auth",
severity="info",
title="login",
summary="login",
details={"user": user},
)
failed = _ingest(
db_session,
t0 + timedelta(seconds=30),
**ws_payload,
type="rdp.login.failed",
category="auth",
severity="warning",
title="failed",
summary="failed",
details={"user": user},
)
qwinsta_stdout = "SESSIONNAME USERNAME ID STATE\n rdp-tcp#0 B26\\TSA 3 Active\n"
monkeypatch.setattr(
"app.services.rdp_flap_auto_disconnect.list_windows_sessions",
lambda host, cfg: (
[],
WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or "", stdout=qwinsta_stdout),
),
)
monkeypatch.setattr(
"app.services.rdp_flap_auto_disconnect.terminate_windows_session",
lambda host, cfg, sid: WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or ""),
)
result = maybe_auto_disconnect_stuck_rdp_session(db_session, failed)
assert result is not None
assert result.ok is True
assert result.session_ids == (3,)
+57
View File
@@ -36,6 +36,16 @@ def _install_fake_paramiko(monkeypatch, *, connect_side_effect=None, exec_setup=
return client return client
def test_ssh_output_hostname_ignores_motd():
stdout = "Welcome!\nFASTPANEL\n\ncz-server\n"
assert ssh_connect._ssh_output_hostname(stdout) == "cz-server"
def test_remote_shell_command_non_login_for_sessions():
cmd = _remote_shell_command("root", "loginctl list-sessions", "secret", login_shell=False)
assert cmd == "bash -c 'loginctl list-sessions'"
def test_remote_shell_command_non_root_probe_has_no_sudo(): def test_remote_shell_command_non_root_probe_has_no_sudo():
cmd = _remote_shell_command("deploy", "hostname", "secret", need_root=False) cmd = _remote_shell_command("deploy", "hostname", "secret", need_root=False)
assert "sudo" not in cmd assert "sudo" not in cmd
@@ -125,6 +135,53 @@ def test_iter_ssh_targets_requires_address():
pass pass
def test_run_ssh_command_retries_transient_no_existing_session(monkeypatch):
attempts = {"count": 0}
class _NoSessionError(Exception):
pass
def connect_side_effect(*args, **kwargs):
attempts["count"] += 1
if attempts["count"] == 1:
raise _NoSessionError("No existing session")
def setup(client):
stdout = MagicMock()
stdout.read.return_value = b"ready\n"
stdout.channel.recv_exit_status.return_value = 0
stderr = MagicMock()
stderr.read.return_value = b""
def exec_ok(cmd, **kwargs):
return (None, stdout, stderr)
client.exec_command.side_effect = exec_ok
fake = MagicMock()
fake.SSHClient = MagicMock()
fake.AutoAddPolicy = MagicMock()
fake.AuthenticationException = _AuthError
def make_client():
client = MagicMock()
client.connect.side_effect = connect_side_effect
setup(client)
return client
fake.SSHClient.side_effect = make_client
monkeypatch.setitem(sys.modules, "paramiko", fake)
result = run_ssh_command(
target="185.87.149.9",
user="root",
password="pw",
remote_cmd="hostname",
)
assert result.ok is True
assert attempts["count"] == 2
def test_probe_ssh_connection_success(monkeypatch): def test_probe_ssh_connection_success(monkeypatch):
def setup(client): def setup(client):
stdout = MagicMock() stdout = MagicMock()
+17 -6
View File
@@ -11,7 +11,8 @@ from app.services.rdp_bundle_delivery import (
) )
from app.services.winrm_connect import ( from app.services.winrm_connect import (
RDP_BUNDLE_REQUIRED, RDP_BUNDLE_REQUIRED,
RDP_REMOTE_STAGING, RDP_LEGACY_STAGING,
RDP_REMOTE_STAGING_DIRNAME,
WinRmCmdResult, WinRmCmdResult,
_clixml_to_plain, _clixml_to_plain,
_custom_deploy_body, _custom_deploy_body,
@@ -37,30 +38,40 @@ def test_decode_winrm_bytes_handles_utf8_multibyte():
def test_deploy_from_staging_includes_deploy_log_tail(): def test_deploy_from_staging_includes_deploy_log_tail():
script = _deploy_from_staging_body(RDP_REMOTE_STAGING) script = _deploy_from_staging_body()
assert "deploy.log" in script assert "deploy.log" in script
assert "Get-Content" in script assert "Get-Content" in script
assert "-Encoding UTF8" in script assert "-Encoding UTF8" in script
assert RDP_REMOTE_STAGING_DIRNAME in script
def test_prepare_staging_recreates_remote_dir(): def test_prepare_staging_recreates_remote_dir():
script = _prepare_staging_body(RDP_REMOTE_STAGING) script = _prepare_staging_body()
assert "Remove-Item" in script assert "Remove-Item" in script
assert "_sac_staging" in script assert RDP_REMOTE_STAGING_DIRNAME in script
assert RDP_LEGACY_STAGING in script
def test_download_bundle_uses_invoke_webrequest(): def test_download_bundle_uses_invoke_webrequest():
script = _download_bundle_body( script = _download_bundle_body(
RDP_REMOTE_STAGING,
"https://sac.example/api/v1/agent/rdp-bundle/token", "https://sac.example/api/v1/agent/rdp-bundle/token",
) )
assert "Invoke-WebRequest" in script assert "Invoke-WebRequest" in script
assert "$env:TEMP" in script
assert RDP_REMOTE_STAGING_DIRNAME in script
assert "ZipFile]::ExtractToDirectory" in script assert "ZipFile]::ExtractToDirectory" in script
assert "_sac_staging" not in script or RDP_LEGACY_STAGING in script
assert "Expand-Archive" not in script assert "Expand-Archive" not in script
def test_winrm_failure_detail_prefers_error_line_over_progress_stdout():
stdout = "Downloading bundle: https://sac.test/bundle\nERROR: destination not empty"
detail = _winrm_failure_detail(stdout, "", 1)
assert detail == "destination not empty"
def test_deploy_from_staging_runs_local_bundle(): def test_deploy_from_staging_runs_local_bundle():
script = _deploy_from_staging_body(RDP_REMOTE_STAGING) script = _deploy_from_staging_body()
assert "-SourceShareRoot" in script assert "-SourceShareRoot" in script
assert "Deploy-LoginMonitor.ps1" in script assert "Deploy-LoginMonitor.ps1" in script
+16
View File
@@ -4,8 +4,13 @@
# systemd НЕ парсит этот файл — читает приложение (SAC_CONFIG_FILE). # systemd НЕ парсит этот файл — читает приложение (SAC_CONFIG_FILE).
DATABASE_URL=postgresql+psycopg2://sac:CHANGE_ME_POSTGRES_PASSWORD@127.0.0.1:5432/sac DATABASE_URL=postgresql+psycopg2://sac:CHANGE_ME_POSTGRES_PASSWORD@127.0.0.1:5432/sac
# SQLAlchemy pool (uvicorn workers × concurrent ingest). Было по умолчанию 5+10 — мало для штурма 09:00.
SAC_DB_POOL_SIZE=15
SAC_DB_MAX_OVERFLOW=25
SAC_PUBLIC_URL=https://sac.kalinamall.ru SAC_PUBLIC_URL=https://sac.kalinamall.ru
# Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS).
# SAC_AGENT_BUNDLE_BASE_URL=https://192.168.x.x
JWT_SECRET=CHANGE_ME_openssl_rand_hex_32 JWT_SECRET=CHANGE_ME_openssl_rand_hex_32
# API key для агентов: Authorization: Bearer <ключ> # API key для агентов: Authorization: Bearer <ключ>
@@ -65,6 +70,8 @@ SAC_DAILY_REPORT_REQUIRE_ACTIVITY=true
SAC_HEARTBEAT_STALE_MINUTES=300 SAC_HEARTBEAT_STALE_MINUTES=300
# Проактивный алерт host_silence (без ожидания нового события с хоста) # Проактивный алерт host_silence (без ожидания нового события с хоста)
# Если включён systemd timer sac-host-silence-scan.timer — лучше SAC_HOST_SILENCE_SCAN_ENABLED=false
# (иначе scan идёт параллельно из каждого uvicorn worker + timer).
SAC_HOST_SILENCE_SCAN_ENABLED=true SAC_HOST_SILENCE_SCAN_ENABLED=true
SAC_HOST_SILENCE_SCAN_INTERVAL_MINUTES=5 SAC_HOST_SILENCE_SCAN_INTERVAL_MINUTES=5
@@ -84,6 +91,9 @@ SAC_RDG_FLAP_WINDOW_MIN_SEC=1
SAC_RDG_FLAP_WINDOW_MAX_SEC=10 SAC_RDG_FLAP_WINDOW_MAX_SEC=10
SAC_RDG_FLAP_DEDUP_SEC=30 SAC_RDG_FLAP_DEDUP_SEC=30
# IP HAProxy (или другого RDG-прокси), через который виден вход: external_ip в событии 302/303
# SAC_RDG_HAPROXY_EXTERNAL_IPS=192.168.160.50,10.0.0.100
# qwinsta/logoff на Windows-хостах (доменный admin) # qwinsta/logoff на Windows-хостах (доменный admin)
# SAC_WIN_ADMIN_USER=B26\\Administrator # SAC_WIN_ADMIN_USER=B26\\Administrator
# SAC_WIN_ADMIN_PASSWORD= # SAC_WIN_ADMIN_PASSWORD=
@@ -100,6 +110,12 @@ SAC_PROBLEMS_RETENTION_DAYS=180
SAC_LOGIN_MAX_FAILURES=3 SAC_LOGIN_MAX_FAILURES=3
SAC_LOGIN_FAILURE_WINDOW_MINUTES=15 SAC_LOGIN_FAILURE_WINDOW_MINUTES=15
SAC_LOGIN_ALERT_TELEGRAM=true SAC_LOGIN_ALERT_TELEGRAM=true
# IP через запятую — не блокируются в UI (дополняет список в Настройках SAC)
SAC_LOGIN_IP_WHITELIST=192.168.160.3
# fail2ban: синхронизация белого списка из UI (нужны права записи + reload)
SAC_FAIL2BAN_SYNC_ENABLED=false
SAC_FAIL2BAN_SSH_JAIL=sshd
SAC_FAIL2BAN_IGNOREIP_FILE=/etc/fail2ban/jail.d/sac-login-whitelist.local
# Seaca mobile push (FCM HTTP v1) # Seaca mobile push (FCM HTTP v1)
SAC_FCM_ENABLED=false SAC_FCM_ENABLED=false
+19
View File
@@ -105,6 +105,25 @@ print('db: OK')
" || die "PostgreSQL: проверьте DATABASE_URL в ${CONFIG_FILE} и systemctl status postgresql" " || die "PostgreSQL: проверьте DATABASE_URL в ${CONFIG_FILE} и systemctl status postgresql"
log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)" log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)"
log "Сброс зависших remote_action (running без worker после restart)"
sudo -u "${APP_USER}" bash -c "
set -a
# shellcheck source=/dev/null
source '${CONFIG_FILE}'
set +a
cd '${APP_ROOT}/backend'
'${VENV}/bin/python' -c \"
from app.database import SessionLocal
from app.services.host_remote_actions import clear_stale_running_remote_actions
session = SessionLocal()
try:
names = clear_stale_running_remote_actions(session)
for name in names:
print(f'stale remote_action reset: {name}')
finally:
session.close()
\"
"
systemctl restart "${SERVICE_NAME}" systemctl restart "${SERVICE_NAME}"
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active" systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active"
+8 -20
View File
@@ -1,19 +1,18 @@
{ {
"name": "sac-ui", "name": "sac-ui",
"version": "0.7.2", "version": "0.11.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sac-ui", "name": "sac-ui",
"version": "0.7.2", "version": "0.11.4",
"dependencies": { "dependencies": {
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"vite": "^6.0.3", "vite": "^6.0.3",
@@ -903,17 +902,6 @@
"win32" "win32"
] ]
}, },
"node_modules/@types/dompurify": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"dompurify": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1151,9 +1139,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.7", "version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
@@ -1452,9 +1440,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.2", "version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -9,14 +9,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.2.4", "dompurify": "^3.4.11",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-router": "^4.5.0" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"vite": "^6.0.3", "vite": "^6.4.3",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.2.0"
} }
} }
+2 -15
View File
@@ -4,16 +4,7 @@
<main :class="showShell ? 'app-main' : ''"> <main :class="showShell ? 'app-main' : ''">
<RouterView /> <RouterView />
</main> </main>
<HostActionLogModal <HostActionLogStack v-if="showShell" />
v-if="showShell"
:open="hostRemoteActionLog.open"
:title="hostRemoteActionLog.title"
:loading="hostRemoteActionLog.loading"
:ok="hostRemoteActionLog.ok"
:message="hostRemoteActionLog.message"
:output="hostRemoteActionLog.output"
@close="closeHostRemoteActionLog"
/>
</div> </div>
</template> </template>
@@ -22,11 +13,7 @@ import { computed, onMounted } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { getToken } from "./api"; import { getToken } from "./api";
import AppSidebar from "./components/AppSidebar.vue"; import AppSidebar from "./components/AppSidebar.vue";
import HostActionLogModal from "./components/HostActionLogModal.vue"; import HostActionLogStack from "./components/HostActionLogStack.vue";
import {
closeHostRemoteActionLog,
hostRemoteActionLog,
} from "./composables/useHostRemoteAction";
import { refreshSessionRole } from "./router"; import { refreshSessionRole } from "./router";
const route = useRoute(); const route = useRoute();
+132
View File
@@ -187,6 +187,9 @@ export interface EventSummary {
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;
rdg_access_path?: string | null;
rdg_qwinsta_enabled?: boolean;
session_terminated?: boolean;
} }
export interface AgentCommandResponse { export interface AgentCommandResponse {
@@ -286,6 +289,9 @@ export interface HostListResponse {
latest_agent_versions?: Record<string, string>; latest_agent_versions?: Record<string, string>;
/** Эталон для «устарела»: max(git latest, min, max в fleet) */ /** Эталон для «устарела»: max(git latest, min, max в fleet) */
reference_agent_versions?: Record<string, string>; reference_agent_versions?: Record<string, string>;
/** Последние версии из git (version.txt на main) */
git_latest_rdp_version?: string | null;
git_latest_ssh_version?: string | null;
} }
export interface ProblemSummary { export interface ProblemSummary {
@@ -466,6 +472,23 @@ export function updateWinAdminSettings(payload: {
}); });
} }
export interface RdpFlapSettings {
auto_disconnect: boolean;
win_admin_configured: boolean;
source: string;
}
export function fetchRdpFlapSettings(): Promise<RdpFlapSettings> {
return apiFetch<RdpFlapSettings>("/api/v1/settings/rdp-flap");
}
export function updateRdpFlapSettings(payload: { auto_disconnect: boolean }): Promise<RdpFlapSettings> {
return apiFetch<RdpFlapSettings>("/api/v1/settings/rdp-flap", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export interface HostWinRmTestResult { export interface HostWinRmTestResult {
ok: boolean; ok: boolean;
message: string; message: string;
@@ -569,10 +592,72 @@ export function runHostAgentUpdateFallback(hostId: number): Promise<HostRemoteAc
); );
} }
export interface HostManualAddRequest {
platform: "windows" | "linux";
target: string;
}
export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteActionStartResult> {
return apiFetch<HostRemoteActionStartResult>("/api/v1/hosts/manual-add", {
method: "POST",
body: JSON.stringify(body),
});
}
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`);
} }
export interface HostSessionItem {
session_id: string;
user: string;
tty?: string | null;
state?: string | null;
source_ip?: string | null;
session_name?: string | null;
}
export interface HostSessionsResponse {
ok: boolean;
message: string;
target?: string | null;
sessions: HostSessionItem[];
}
export interface HostSessionTerminateResponse {
ok: boolean;
message: string;
target?: string | null;
stdout?: string | null;
stderr?: string | null;
}
export function fetchHostSessions(hostId: number): Promise<HostSessionsResponse> {
return apiFetch<HostSessionsResponse>(`/api/v1/hosts/${hostId}/actions/sessions/list`, {
method: "POST",
});
}
export function terminateHostSession(
hostId: number,
sessionId: string,
): Promise<HostSessionTerminateResponse> {
return apiFetch<HostSessionTerminateResponse>(`/api/v1/hosts/${hostId}/actions/sessions/terminate`, {
method: "POST",
body: JSON.stringify({ session_id: sessionId }),
});
}
export function postEventTerminateSession(
eventId: number,
sessionId?: string,
): Promise<HostSessionTerminateResponse> {
return apiFetch<HostSessionTerminateResponse>(`/api/v1/events/${eventId}/actions/terminate-session`, {
method: "POST",
body: JSON.stringify(sessionId ? { session_id: sessionId } : {}),
});
}
export interface HostAgentConfigResponse { export interface HostAgentConfigResponse {
config_revision: number; config_revision: number;
settings: Record<string, unknown>; settings: Record<string, unknown>;
@@ -641,6 +726,53 @@ export function testAgentGitRelease(payload?: AgentGitTestPayload): Promise<Agen
}); });
} }
export interface LoginSecuritySettings {
ip_whitelist: string[];
ip_whitelist_text: string;
sync_fail2ban: boolean;
max_failures: number;
window_minutes: number;
fail2ban_available: boolean;
source: string;
}
export interface LoginBlockItem {
ip_address: string;
scope: string;
failure_count: number | null;
usernames: string[];
blocked_until: string | null;
reason: string;
}
export function fetchLoginSecuritySettings(): Promise<LoginSecuritySettings> {
return apiFetch<LoginSecuritySettings>("/api/v1/settings/login-security");
}
export function saveLoginSecuritySettings(payload: {
ip_whitelist_text: string;
sync_fail2ban: boolean;
}): Promise<LoginSecuritySettings> {
return apiFetch<LoginSecuritySettings>("/api/v1/settings/login-security", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export function fetchLoginSecurityBlocks(): Promise<LoginBlockItem[]> {
return apiFetch<LoginBlockItem[]>("/api/v1/settings/login-security/blocks");
}
export function unblockLoginSecurityIp(payload: {
ip_address: string;
scope: "web" | "ssh" | "both";
}): Promise<{ messages: string[] }> {
return apiFetch<{ messages: string[] }>("/api/v1/settings/login-security/unblock", {
method: "POST",
body: JSON.stringify(payload),
});
}
export interface DashboardSummary { export interface DashboardSummary {
events_last_24h: number; events_last_24h: number;
hosts_total: number; hosts_total: number;
+5 -5
View File
@@ -12,11 +12,11 @@
v-if="remoteJobRunning && !remoteJobPanelOpen" v-if="remoteJobRunning && !remoteJobPanelOpen"
type="button" type="button"
class="sac-nav-item sac-remote-job-badge" class="sac-nav-item sac-remote-job-badge"
title="Обновление агента выполняется на сервере SAC" title="Обновления агентов выполняются на сервере SAC"
@click="showHostRemoteActionLog" @click="showHostRemoteActionLog()"
> >
<span class="sac-nav-icon sac-remote-job-spinner" aria-hidden="true" /> <span class="sac-nav-icon sac-remote-job-spinner" aria-hidden="true" />
<span class="sac-nav-label">Обновление</span> <span class="sac-nav-label">Обновления</span>
</button> </button>
<RouterLink <RouterLink
v-for="item in navItems" v-for="item in navItems"
@@ -46,8 +46,8 @@ import { clearToken, isAdmin } from "../api";
import SidebarSystemStats from "./SidebarSystemStats.vue"; import SidebarSystemStats from "./SidebarSystemStats.vue";
import { useSacVersion } from "../composables/useSacVersion"; import { useSacVersion } from "../composables/useSacVersion";
import { import {
anyRemoteActionLogOpen,
hasActiveHostRemoteAction, hasActiveHostRemoteAction,
hostRemoteActionLog,
showHostRemoteActionLog, showHostRemoteActionLog,
} from "../composables/useHostRemoteAction"; } from "../composables/useHostRemoteAction";
@@ -55,7 +55,7 @@ const { appName, appVersion } = useSacVersion();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const remoteJobRunning = computed(() => hasActiveHostRemoteAction()); const remoteJobRunning = computed(() => hasActiveHostRemoteAction());
const remoteJobPanelOpen = computed(() => hostRemoteActionLog.open); const remoteJobPanelOpen = computed(() => anyRemoteActionLogOpen());
const navItems = computed(() => { const navItems = computed(() => {
const items = [ const items = [
@@ -15,7 +15,7 @@
</div> </div>
<p v-if="loading" class="host-action-log-status"> <p v-if="loading" class="host-action-log-status">
<span class="host-action-log-spinner" aria-hidden="true" /> <span class="host-action-log-spinner" aria-hidden="true" />
Выполняется на удалённом хосте Можно перейти в другой раздел SAC. Выполняется на удалённом хосте Можно запустить обновление других хостов.
</p> </p>
<p v-if="message && !loading" class="host-action-log-message" :class="messageClass">{{ message }}</p> <p v-if="message && !loading" class="host-action-log-message" :class="messageClass">{{ message }}</p>
<p v-else-if="message && loading" class="muted host-action-log-sub host-action-log-message">{{ message }}</p> <p v-else-if="message && loading" class="muted host-action-log-sub host-action-log-message">{{ message }}</p>
@@ -56,12 +56,7 @@ const messageClass = computed(() => {
<style scoped> <style scoped>
.host-action-log-dock { .host-action-log-dock {
position: fixed; width: 100%;
right: 1rem;
bottom: 1rem;
z-index: 1000;
width: min(56rem, calc(100vw - 2rem));
max-width: calc(100vw - 2rem);
pointer-events: none; pointer-events: none;
} }
@@ -0,0 +1,49 @@
<template>
<div v-if="openLogs.length" class="host-action-log-stack" aria-live="polite">
<HostActionLogModal
v-for="entry in openLogs"
:key="entry.id"
:open="true"
:title="entry.title"
:loading="entry.loading"
:ok="entry.ok"
:message="entry.message"
:output="entry.output"
@close="closeHostRemoteActionLog(entry.id)"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import HostActionLogModal from "./HostActionLogModal.vue";
import {
closeHostRemoteActionLog,
hostRemoteActionLogs,
} from "../composables/useHostRemoteAction";
const openLogs = computed(() => hostRemoteActionLogs.filter((e) => e.open));
</script>
<style scoped>
.host-action-log-stack {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 1000;
display: flex;
flex-direction: column-reverse;
align-items: flex-end;
gap: 0.65rem;
width: min(56rem, calc(100vw - 2rem));
max-height: min(90vh, 48rem);
overflow-y: auto;
pointer-events: none;
}
.host-action-log-stack :deep(.host-action-log-panel) {
pointer-events: auto;
width: 100%;
max-height: min(36rem, 45vh);
}
</style>
@@ -0,0 +1,135 @@
<template>
<span class="agent-version-cell">
<span :class="{ 'agent-version-outdated': outdatedHighlight }" :title="currentTitle">
{{ host.product_version || "—" }}
</span>
<button
v-if="showUpgrade"
type="button"
class="agent-version-upgrade"
:class="{ 'agent-version-upgrade--updating': updating }"
:disabled="updating"
:title="buttonTitle"
@click.stop="emit('upgrade')"
>
<span class="agent-version-upgrade__arrow" aria-hidden="true"></span>
<span class="agent-version-upgrade__target">{{ gitTargetVersion }}</span>
</button>
<span v-if="updating" class="agent-version-upgrade__busy muted"></span>
</span>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { HostSummary } from "../api";
import {
gitTargetVersionForHost,
isGitAgentUpgradeAvailable,
type AgentGitVersions,
} from "../utils/hostAgentUpgrade";
const props = defineProps<{
host: HostSummary;
git: AgentGitVersions | null | undefined;
updating?: boolean;
outdatedHighlight?: boolean;
}>();
const emit = defineEmits<{
upgrade: [];
}>();
const gitTargetVersion = computed(() => gitTargetVersionForHost(props.host, props.git));
const showUpgrade = computed(() => isGitAgentUpgradeAvailable(props.host, props.git));
const currentTitle = computed(() => {
if (!props.host.product_version) return "";
if (showUpgrade.value && gitTargetVersion.value) {
return `Текущая версия ${props.host.product_version}. Доступна ${gitTargetVersion.value}`;
}
return props.host.product_version;
});
const upgradeTitle = computed(() => {
if (!gitTargetVersion.value) return "";
return `Обновить агент до ${gitTargetVersion.value}`;
});
const buttonTitle = computed(() => {
if (props.updating) {
return "Обновление выполняется…";
}
return upgradeTitle.value;
});
</script>
<style scoped>
.agent-version-cell {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.2rem 0.35rem;
}
.agent-version-upgrade {
display: inline-flex;
align-items: center;
gap: 0.15rem;
margin: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
font: inherit;
line-height: 1.2;
}
.agent-version-upgrade:disabled {
cursor: not-allowed;
}
.agent-version-upgrade__arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: rgba(63, 185, 80, 0.18);
color: #3fb950;
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
}
.agent-version-upgrade__target {
color: #3fb950;
font-size: 0.92em;
font-weight: 600;
text-decoration: underline;
text-underline-offset: 2px;
}
.agent-version-upgrade:hover:not(:disabled) .agent-version-upgrade__target {
color: #56d364;
}
.agent-version-upgrade:hover:not(:disabled) .agent-version-upgrade__arrow {
background: rgba(63, 185, 80, 0.28);
}
.agent-version-upgrade--updating .agent-version-upgrade__arrow {
background: rgba(139, 148, 158, 0.2);
color: #8b949e;
}
.agent-version-upgrade--updating .agent-version-upgrade__target {
color: #8b949e;
text-decoration: none;
}
.agent-version-upgrade__busy {
font-size: 0.85em;
}
</style>
@@ -0,0 +1,157 @@
<template>
<div v-if="open" class="modal-backdrop" @click.self="emit('close')">
<div class="modal-card host-manual-add-modal" role="dialog" aria-modal="true" aria-label="Добавить хост вручную">
<h3>Добавить хост вручную</h3>
<p class="muted">
SAC проверит подключение и установит агент (RDP-login-monitor или ssh-monitor) через WinRM / SSH.
</p>
<fieldset class="platform-fieldset">
<legend>Тип хоста</legend>
<label class="platform-option">
<input v-model="platform" type="radio" value="windows" :disabled="submitting" />
Windows только по имени ПК (WinRM)
</label>
<label class="platform-option">
<input v-model="platform" type="radio" value="linux" :disabled="submitting" />
Linux IP или hostname (SSH)
</label>
</fieldset>
<label class="target-label">
{{ platform === "windows" ? "Имя ПК (NetBIOS / DNS)" : "IP или hostname" }}
<input
v-model="target"
type="text"
:placeholder="platform === 'windows' ? 'WORKSTATION-01' : '10.10.36.9'"
:disabled="submitting"
@keyup.enter="submit"
/>
</label>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="submitting" class="muted">Проверка WinRM/SSH и запуск установки</p>
<div class="modal-actions">
<button type="button" class="secondary" :disabled="submitting" @click="emit('close')">Отмена</button>
<button type="button" :disabled="submitting || !target.trim()" @click="submit">
{{ submitting ? "Выполняется…" : "Добавить и установить" }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { addHostManually, ApiError } from "../api";
const props = defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
close: [];
started: [payload: { hostId: number; title: string }];
}>();
const platform = ref<"windows" | "linux">("windows");
const target = ref("");
const submitting = ref(false);
const error = ref("");
watch(
() => props.open,
(isOpen) => {
if (!isOpen) return;
platform.value = "windows";
target.value = "";
submitting.value = false;
error.value = "";
},
);
async function submit() {
const trimmed = target.value.trim();
if (!trimmed || submitting.value) return;
submitting.value = true;
error.value = "";
try {
const result = await addHostManually({
platform: platform.value,
target: trimmed,
});
emit("started", { hostId: result.host_id, title: result.title });
emit("close");
} catch (e) {
error.value =
e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Не удалось добавить хост";
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.55);
}
.modal-card {
width: min(100%, 28rem);
padding: 1rem 1.1rem 1.1rem;
background: var(--card-bg, #1e2430);
border: 1px solid var(--border, #3a4556);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.modal-card h3 {
margin: 0 0 0.5rem;
}
.platform-fieldset {
margin: 0.75rem 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border, #3a4556);
border-radius: 6px;
}
.platform-fieldset legend {
padding: 0 0.25rem;
font-size: 0.85rem;
color: #9aa4b2;
}
.platform-option {
display: block;
margin: 0.35rem 0;
font-size: 0.92rem;
}
.target-label {
display: block;
margin-top: 0.75rem;
}
.target-label input {
display: block;
width: 100%;
margin-top: 0.35rem;
box-sizing: border-box;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
</style>
@@ -0,0 +1,209 @@
<template>
<section
ref="rootRef"
class="card host-sessions"
:class="{ 'host-sessions--expanded': loaded }"
:style="panelMinHeight ? { minHeight: panelMinHeight } : undefined"
>
<h2>Залогиненные пользователи</h2>
<p class="muted">
Текущие сессии на хосте (Linux: loginctl, Windows: qwinsta). Требуются учётные данные admin в настройках SAC.
</p>
<p v-if="error" class="error">{{ error }}</p>
<template v-if="loaded">
<p v-if="!sessions.length" class="muted">Активных сессий не найдено.</p>
<table v-else>
<thead>
<tr>
<th>Пользователь</th>
<th>Session</th>
<th>TTY / имя</th>
<th>Состояние</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="s in sessions" :key="s.session_id + s.user">
<td>{{ s.user }}</td>
<td><code>{{ s.session_id }}</code></td>
<td>{{ s.tty || s.session_name || "—" }}</td>
<td>{{ s.state || "—" }}</td>
<td>
<button
type="button"
class="secondary"
:disabled="terminatingId === s.session_id"
@click="terminate(s.session_id)"
>
{{ terminatingId === s.session_id ? "…" : "Завершить" }}
</button>
</td>
</tr>
</tbody>
</table>
<p v-if="message" :class="messageOk ? 'success' : 'error'">{{ message }}</p>
</template>
<div class="host-sessions-footer">
<button type="button" class="secondary" :disabled="loading" @click="load">
{{ loading ? "Загрузка…" : loaded ? "Обновить" : "Показать пользователей" }}
</button>
</div>
</section>
</template>
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import {
fetchHostSessions,
terminateHostSession,
type HostSessionItem,
} from "../api";
const props = defineProps<{
hostId: number;
heightSource?: HTMLElement | null;
}>();
const rootRef = ref<HTMLElement | null>(null);
const loading = ref(false);
const loaded = ref(false);
const sessions = ref<HostSessionItem[]>([]);
const error = ref("");
const message = ref("");
const messageOk = ref(true);
const terminatingId = ref<string | null>(null);
const panelMinHeight = ref<string | undefined>(undefined);
let resizeObserver: ResizeObserver | null = null;
let observedSource: HTMLElement | null = null;
function updateMatchedHeight() {
if (loaded.value) {
panelMinHeight.value = undefined;
return;
}
const source = props.heightSource;
if (!source) {
panelMinHeight.value = undefined;
return;
}
panelMinHeight.value = `${source.offsetHeight}px`;
}
function bindHeightObserver(source: HTMLElement | null | undefined) {
if (observedSource && resizeObserver) {
resizeObserver.unobserve(observedSource);
observedSource = null;
}
if (!source || typeof ResizeObserver === "undefined") {
updateMatchedHeight();
return;
}
if (!resizeObserver) {
resizeObserver = new ResizeObserver(() => updateMatchedHeight());
}
resizeObserver.observe(source);
observedSource = source;
updateMatchedHeight();
}
function resetSessionsState() {
loaded.value = false;
sessions.value = [];
error.value = "";
message.value = "";
terminatingId.value = null;
}
async function load() {
loading.value = true;
error.value = "";
message.value = "";
try {
const res = await fetchHostSessions(props.hostId);
if (!res.ok) {
error.value = res.message;
sessions.value = [];
} else {
sessions.value = res.sessions;
loaded.value = true;
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки сессий";
} finally {
loading.value = false;
await nextTick();
updateMatchedHeight();
}
}
async function terminate(sessionId: string) {
if (!window.confirm(`Завершить сессию ${sessionId}?`)) return;
terminatingId.value = sessionId;
message.value = "";
try {
const res = await terminateHostSession(props.hostId, sessionId);
messageOk.value = res.ok;
message.value = res.message;
if (res.ok) {
sessions.value = sessions.value.filter((s) => s.session_id !== sessionId);
}
} catch (e) {
messageOk.value = false;
message.value = e instanceof Error ? e.message : "Ошибка завершения сессии";
} finally {
terminatingId.value = null;
}
}
watch(
() => props.heightSource,
(source) => {
bindHeightObserver(source ?? null);
},
);
watch(
() => props.hostId,
() => {
resetSessionsState();
nextTick(() => updateMatchedHeight());
},
);
watch(loaded, () => {
nextTick(() => updateMatchedHeight());
});
onMounted(() => {
bindHeightObserver(props.heightSource ?? null);
});
onUnmounted(() => {
resizeObserver?.disconnect();
resizeObserver = null;
observedSource = null;
});
</script>
<style scoped>
.host-sessions {
margin-top: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.host-sessions:not(.host-sessions--expanded) .host-sessions-footer {
margin-top: auto;
}
.host-sessions table {
width: 100%;
margin: 0.75rem 0;
}
.host-sessions-footer {
padding-top: 0.5rem;
}
</style>
@@ -0,0 +1,40 @@
<template>
<span class="rdg-access-badge" :title="tooltip">{{ event.rdg_access_path }}</span>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { EventSummary } from "../api";
const props = defineProps<{
event: EventSummary;
}>();
const tooltip = computed(() => {
const path = props.event.rdg_access_path;
if (path === "Haproxy-RDG-Comp") {
return "Вход на рабочий ПК через HAProxy → RD Gateway";
}
if (path === "RDG-Comp") {
return "Прямой вход на рабочий ПК через RD Gateway";
}
return "Путь входа RDS через RD Gateway";
});
</script>
<style scoped>
.rdg-access-badge {
display: inline-block;
margin-right: 0.45rem;
padding: 0.1rem 0.45rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
color: #7ec8e3;
background: rgba(126, 200, 227, 0.1);
border: 1px solid rgba(126, 200, 227, 0.4);
border-radius: 4px;
vertical-align: middle;
white-space: nowrap;
}
</style>
+197 -61
View File
@@ -12,60 +12,94 @@ import { emitHostPatch } from "../utils/hostPatchBus";
export type HostRemoteActionKind = "fallback" | "ssh-update"; export type HostRemoteActionKind = "fallback" | "ssh-update";
export const hostRemoteActionLog = reactive({ export type HostRemoteActionLogEntry = {
open: false, id: string;
title: "", hostId: number;
loading: false, open: boolean;
ok: null as boolean | null, loading: boolean;
message: "", ok: boolean | null;
output: "", title: string;
hostId: 0, message: string;
}); output: string;
};
export const hostRemoteActionLogs = reactive<HostRemoteActionLogEntry[]>([]);
const POLL_MS = 1500; const POLL_MS = 1500;
const SUCCESS_AUTO_CLOSE_MS = 30_000; const SUCCESS_AUTO_CLOSE_MS = 30_000;
let successAutoCloseTimer: ReturnType<typeof setTimeout> | null = null; let nextLogSeq = 1;
const autoCloseTimers = new Map<string, ReturnType<typeof setTimeout>>();
const hostRunningPromises = new Map<number, Promise<HostRemoteActionJobStatus | null>>();
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
function clearSuccessAutoCloseTimer() { function newLogId(): string {
if (successAutoCloseTimer != null) { return `ra-${Date.now()}-${nextLogSeq++}`;
clearTimeout(successAutoCloseTimer); }
successAutoCloseTimer = null;
function findLogById(entryId: string): HostRemoteActionLogEntry | undefined {
return hostRemoteActionLogs.find((e) => e.id === entryId);
}
function findLoadingLogForHost(hostId: number): HostRemoteActionLogEntry | undefined {
return hostRemoteActionLogs.find((e) => e.hostId === hostId && e.loading);
}
function clearAutoCloseTimer(entryId: string) {
const timer = autoCloseTimers.get(entryId);
if (timer != null) {
clearTimeout(timer);
autoCloseTimers.delete(entryId);
} }
} }
function scheduleSuccessAutoClose() { function removeClosedFinishedLog(entryId: string) {
clearSuccessAutoCloseTimer(); const idx = hostRemoteActionLogs.findIndex((e) => e.id === entryId);
successAutoCloseTimer = setTimeout(() => { if (idx < 0) return;
hostRemoteActionLog.open = false; const entry = hostRemoteActionLogs[idx];
successAutoCloseTimer = null; if (!entry.loading && !entry.open) {
}, SUCCESS_AUTO_CLOSE_MS); hostRemoteActionLogs.splice(idx, 1);
}
function applyJobToModal(job: HostRemoteActionJobStatus) {
hostRemoteActionLog.title = job.title || hostRemoteActionLog.title;
hostRemoteActionLog.message = job.message || hostRemoteActionLog.message;
hostRemoteActionLog.output = job.output || job.stdout || "";
}
function finishModal(job: HostRemoteActionJobStatus) {
hostRemoteActionLog.loading = false;
hostRemoteActionLog.ok = job.ok ?? job.status === "success";
applyJobToModal(job);
hostRemoteActionLog.open = true;
if (hostRemoteActionLog.ok) {
scheduleSuccessAutoClose();
} }
} }
async function pollRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> { function scheduleSuccessAutoClose(entryId: string) {
clearAutoCloseTimer(entryId);
autoCloseTimers.set(
entryId,
setTimeout(() => {
const entry = findLogById(entryId);
if (entry) {
entry.open = false;
removeClosedFinishedLog(entryId);
}
autoCloseTimers.delete(entryId);
}, SUCCESS_AUTO_CLOSE_MS),
);
}
function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
entry.title = job.title || entry.title;
entry.message = job.message || entry.message;
entry.output = job.output || job.stdout || "";
}
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
entry.loading = false;
entry.ok = job.ok ?? job.status === "success";
applyJobToEntry(entry, job);
entry.open = true;
if (entry.ok) {
scheduleSuccessAutoClose(entry.id);
}
}
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
for (;;) { for (;;) {
const job = await fetchHostRemoteJob(hostId); const job = await fetchHostRemoteJob(hostId);
applyJobToModal(job); applyJobToEntry(entry, job);
if (!job.active && job.status !== "running") { if (!job.active && job.status !== "running") {
return job; return job;
} }
@@ -99,20 +133,12 @@ async function startRemoteAction(hostId: number, kind: HostRemoteActionKind) {
await updateHostAgentViaSsh(hostId); await updateHostAgentViaSsh(hostId);
} }
export async function runHostRemoteAction( async function executeRemoteAction(
hostId: number, hostId: number,
title: string, title: string,
kind: HostRemoteActionKind, kind: HostRemoteActionKind,
entry: HostRemoteActionLogEntry,
): Promise<HostRemoteActionJobStatus | null> { ): Promise<HostRemoteActionJobStatus | null> {
clearSuccessAutoCloseTimer();
hostRemoteActionLog.open = true;
hostRemoteActionLog.loading = true;
hostRemoteActionLog.ok = null;
hostRemoteActionLog.title = title;
hostRemoteActionLog.message = "Запуск на сервере SAC…";
hostRemoteActionLog.output = "";
hostRemoteActionLog.hostId = hostId;
try { try {
try { try {
await startRemoteAction(hostId, kind); await startRemoteAction(hostId, kind);
@@ -123,31 +149,141 @@ export async function runHostRemoteAction(
throw e; throw e;
} }
} }
hostRemoteActionLog.message = entry.message = "Выполняется на удалённом хосте… Можно запустить обновление других хостов.";
"Выполняется на удалённом хосте… Можно перейти в другой раздел SAC."; const job = await pollRemoteJob(hostId, entry);
const job = await pollRemoteJob(hostId); finishEntry(entry, job);
finishModal(job);
await patchHostFromJob(hostId, job); await patchHostFromJob(hostId, job);
return job; return job;
} catch (e) { } catch (e) {
hostRemoteActionLog.loading = false; entry.loading = false;
hostRemoteActionLog.ok = false; entry.ok = false;
hostRemoteActionLog.message = e instanceof Error ? e.message : "Ошибка выполнения"; entry.message = e instanceof Error ? e.message : "Ошибка выполнения";
entry.open = true;
return null; return null;
} }
} }
export function closeHostRemoteActionLog() { export async function pollHostRemoteAction(
clearSuccessAutoCloseTimer(); hostId: number,
hostRemoteActionLog.open = false; title: string,
): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) {
const existing = findLoadingLogForHost(hostId);
if (existing) {
existing.open = true;
}
return existingPromise;
} }
export function showHostRemoteActionLog() { const entry: HostRemoteActionLogEntry = {
if (hostRemoteActionLog.loading || hostRemoteActionLog.ok != null) { id: newLogId(),
hostRemoteActionLog.open = true; hostId,
open: true,
loading: true,
ok: null,
title,
message: "Выполняется на удалённом хосте…",
output: "",
};
hostRemoteActionLogs.push(entry);
const promise = (async () => {
try {
const job = await pollRemoteJob(hostId, entry);
finishEntry(entry, job);
await patchHostFromJob(hostId, job);
return job;
} catch (e) {
entry.loading = false;
entry.ok = false;
entry.message = e instanceof Error ? e.message : "Ошибка выполнения";
entry.open = true;
return null;
} }
})();
hostRunningPromises.set(hostId, promise);
try {
return await promise;
} finally {
hostRunningPromises.delete(hostId);
}
}
export async function runHostRemoteAction(
hostId: number,
title: string,
kind: HostRemoteActionKind,
): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) {
const existing = findLoadingLogForHost(hostId);
if (existing) {
existing.open = true;
}
return existingPromise;
}
const entry: HostRemoteActionLogEntry = {
id: newLogId(),
hostId,
open: true,
loading: true,
ok: null,
title,
message: "Запуск на сервере SAC…",
output: "",
};
hostRemoteActionLogs.push(entry);
const promise = executeRemoteAction(hostId, title, kind, entry);
hostRunningPromises.set(hostId, promise);
try {
return await promise;
} finally {
hostRunningPromises.delete(hostId);
}
}
export function closeHostRemoteActionLog(entryId: string) {
clearAutoCloseTimer(entryId);
const entry = findLogById(entryId);
if (!entry) return;
entry.open = false;
if (!entry.loading) {
removeClosedFinishedLog(entryId);
}
}
export function showHostRemoteActionLog(hostId?: number) {
if (hostId != null) {
for (const entry of hostRemoteActionLogs) {
if (entry.hostId === hostId && (entry.loading || entry.ok != null)) {
entry.open = true;
}
}
return;
}
for (const entry of hostRemoteActionLogs) {
if (entry.loading || entry.ok === false) {
entry.open = true;
}
}
}
export function isHostRemoteActionActive(hostId: number): boolean {
return hostRemoteActionLogs.some((e) => e.hostId === hostId && e.loading);
} }
export function hasActiveHostRemoteAction(): boolean { export function hasActiveHostRemoteAction(): boolean {
return hostRemoteActionLog.loading; return hostRemoteActionLogs.some((e) => e.loading);
}
export function hasOpenRemoteActionLogForHost(hostId: number): boolean {
return hostRemoteActionLogs.some((e) => e.hostId === hostId && e.open);
}
export function anyRemoteActionLogOpen(): boolean {
return hostRemoteActionLogs.some((e) => e.open);
} }
+2 -1
View File
@@ -6,6 +6,7 @@ import {
type AgentCommandResponse, type AgentCommandResponse,
type EventSummary, type EventSummary,
} from "../api"; } from "../api";
import { rdgQwinstaEventId } from "../utils/rdgFlap";
export type QwinstaSessionRow = { export type QwinstaSessionRow = {
sessionName: string; sessionName: string;
@@ -114,7 +115,7 @@ export function useRdgQwinsta() {
} }
async function runQwinsta(event: EventSummary) { async function runQwinsta(event: EventSummary) {
const targetId = event.rdg_flap_qwinsta_event_id ?? event.id; const targetId = rdgQwinstaEventId(event) ?? event.id;
qwinstaLoadingId.value = event.id; qwinstaLoadingId.value = event.id;
qwinstaModal.value = { qwinstaModal.value = {
open: true, open: true,
+11
View File
@@ -53,3 +53,14 @@ export function isAgentVersionNewer(
if (!prev) return true; if (!prev) return true;
return compareVersions(next, prev) > 0; return compareVersions(next, prev) > 0;
} }
/** Хост строго младше целевой версии (например из git), без порога lag. */
export function isAgentVersionBehind(
hostVersion: string | null | undefined,
targetVersion: string | null | undefined,
): boolean {
const host = parseAgentVersion(hostVersion);
const target = parseAgentVersion(targetVersion);
if (!host || !target) return false;
return compareVersions(host, target) < 0;
}
+60
View File
@@ -0,0 +1,60 @@
import type { HostSummary } from "../api";
import type { HostRemoteActionKind } from "../composables/useHostRemoteAction";
import { isAgentVersionBehind } from "./agentVersion";
export const PRODUCT_RDP = "rdp-login-monitor";
export const PRODUCT_SSH = "ssh-monitor";
export interface AgentGitVersions {
git_latest_rdp_version?: string | null;
git_latest_ssh_version?: string | null;
}
export function isWindowsAgentHost(host: Pick<HostSummary, "os_family" | "product">): boolean {
if ((host.os_family || "").toLowerCase() === "windows") return true;
return host.product === PRODUCT_RDP;
}
export function isLinuxAgentHost(host: Pick<HostSummary, "os_family" | "product">): boolean {
if ((host.os_family || "").toLowerCase() === "linux") return true;
return host.product === PRODUCT_SSH;
}
export function gitTargetVersionForHost(
host: Pick<HostSummary, "os_family" | "product">,
git: AgentGitVersions | null | undefined,
): string | null {
if (!git) return null;
if (isWindowsAgentHost(host)) {
return git.git_latest_rdp_version?.trim() || null;
}
if (isLinuxAgentHost(host)) {
return git.git_latest_ssh_version?.trim() || null;
}
return null;
}
export function isGitAgentUpgradeAvailable(
host: Pick<HostSummary, "os_family" | "product" | "product_version">,
git: AgentGitVersions | null | undefined,
): boolean {
const target = gitTargetVersionForHost(host, git);
if (!target || !host.product_version?.trim()) return false;
return isAgentVersionBehind(host.product_version, target);
}
export function remoteActionKindForHost(
host: Pick<HostSummary, "os_family" | "product">,
): HostRemoteActionKind | null {
if (isWindowsAgentHost(host)) return "fallback";
if (isLinuxAgentHost(host)) return "ssh-update";
return null;
}
export function remoteActionTitleForHost(host: HostSummary): string {
const label = host.display_name || host.hostname;
if (isWindowsAgentHost(host)) {
return `Обновление ${label} через WinRM`;
}
return `Обновление ssh-monitor (SSH): ${label}`;
}
+37
View File
@@ -0,0 +1,37 @@
import type { EventListResponse, EventSummary, HostDetail } from "../api";
import { isAgentVersionBehind } from "./agentVersion";
import type { AgentGitVersions } from "./hostAgentUpgrade";
import { gitTargetVersionForHost } from "./hostAgentUpgrade";
/** Точечное обновление карточки хоста по событию ingest (без полного GET /hosts/{id}). */
export function patchHostDetailFromEvent(host: HostDetail, ev: EventSummary): void {
host.last_seen_at = ev.received_at || ev.occurred_at;
host.event_count = (host.event_count ?? 0) + 1;
if (ev.product_version?.trim()) {
host.product_version = ev.product_version.trim();
}
}
export function recalcVersionStatusFromGit(
host: HostDetail,
git: AgentGitVersions | null | undefined,
): void {
const target = gitTargetVersionForHost(host, git);
if (!host.product_version?.trim() || !target) {
return;
}
host.version_status = isAgentVersionBehind(host.product_version, target) ? "outdated" : "ok";
}
/** Добавляет событие в начало списка на стр. 1, если его ещё нет. */
export function prependHostEventIfMissing(
events: EventListResponse | null,
ev: EventSummary,
page: number,
): boolean {
if (!events || page !== 1) return false;
if (events.items.some((row) => row.id === ev.id)) return false;
events.items = [ev, ...events.items];
events.total = (events.total ?? 0) + 1;
return true;
}
+19 -2
View File
@@ -4,12 +4,29 @@ export function hasRdgFlapUi(event: EventSummary): boolean {
return Boolean(event.rdg_flap || event.rdg_flap_qwinsta_event_id); return Boolean(event.rdg_flap || event.rdg_flap_qwinsta_event_id);
} }
export function hasRdgAccessPath(event: EventSummary): boolean {
return Boolean(event.rdg_access_path);
}
export function rdgAccessPathLabel(event: EventSummary): string {
return event.rdg_access_path || "";
}
export function rdgQwinstaEventId(event: EventSummary): number | null {
if (!event.rdg_qwinsta_enabled) return null;
return event.rdg_flap_qwinsta_event_id ?? event.id;
}
/** @deprecated use rdgQwinstaEventId */
export function rdgFlapQwinstaEventId(event: EventSummary): number | null { export function rdgFlapQwinstaEventId(event: EventSummary): number | null {
if (event.rdg_flap_qwinsta_event_id) return event.rdg_flap_qwinsta_event_id; return rdgQwinstaEventId(event);
return event.rdg_flap ? event.id : null;
} }
export function rdgFlapPairLabel(event: EventSummary): string { export function rdgFlapPairLabel(event: EventSummary): string {
if (!event.rdg_flap_pair_event_id) return ""; if (!event.rdg_flap_pair_event_id) return "";
return event.type === "rdg.connection.success" ? "→" : "←"; return event.type === "rdg.connection.success" ? "→" : "←";
} }
export function isRdgConnectionEvent(event: EventSummary): boolean {
return event.type.startsWith("rdg.connection.");
}
+32
View File
@@ -0,0 +1,32 @@
import type { EventSummary } from "../api";
const LINUX_SESSION_EVENT_TYPES = new Set([
"session.logind.new",
"ssh.login.success",
"privilege.sudo.command",
]);
const WINDOWS_SESSION_EVENT_TYPES = new Set(["rdp.login.success"]);
export function eventSupportsSessionTerminate(event: Pick<EventSummary, "type">): boolean {
return (
LINUX_SESSION_EVENT_TYPES.has(event.type) ||
WINDOWS_SESSION_EVENT_TYPES.has(event.type)
);
}
export function showEventSessionTerminateButton(
event: Pick<EventSummary, "type" | "session_terminated">,
): boolean {
return eventSupportsSessionTerminate(event) && !event.session_terminated;
}
export function markEventSessionTerminatedInList(
items: EventSummary[] | undefined,
eventId: number,
): void {
const item = items?.find((row) => row.id === eventId);
if (item) {
item.session_terminated = true;
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center"; export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.20.26"; export const APP_VERSION = "0.4.11";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+40 -12
View File
@@ -1,7 +1,6 @@
<template> <template>
<div class="overview-page"> <div class="overview-page">
<h1>Обзор</h1> <h1>Обзор</h1>
<p class="dashboard-sac-version">{{ appName }} v.{{ appVersion }}</p>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
@@ -282,6 +281,7 @@
<td :class="'sev-' + e.severity">{{ e.severity }}</td> <td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td> <td>
<RdgAccessBadge v-if="e.rdg_access_path" :event="e" />
<RdgFlapBadge v-if="hasRdgFlapUi(e)" :event="e" /> <RdgFlapBadge v-if="hasRdgFlapUi(e)" :event="e" />
{{ e.title }} {{ e.title }}
</td> </td>
@@ -289,13 +289,23 @@
<td class="dash-actions"> <td class="dash-actions">
<button <button
v-if="rdgFlapQwinstaEventId(e)" v-if="rdgQwinstaEventId(e)"
type="button" type="button"
class="secondary dash-qwinsta-btn" class="secondary dash-qwinsta-btn"
:disabled="qwinstaLoadingId === e.id" :disabled="qwinstaLoadingId === e.id"
title="qwinsta и завершение сеанса на рабочем ПК"
@click="runQwinsta(e)" @click="runQwinsta(e)"
> >
qwinsta Обрыв RDS
</button>
<button
v-if="showEventSessionTerminateButton(e)"
type="button"
class="secondary dash-qwinsta-btn"
:disabled="sessionTerminateLoadingId === e.id"
@click="runSessionTerminate(e)"
>
{{ sessionTerminateLoadingId === e.id ? "…" : "Завершить" }}
</button> </button>
</td> </td>
@@ -332,19 +342,22 @@ import { onMounted, onUnmounted, ref } from "vue";
import { import {
apiFetch, apiFetch,
ApiError,
getToken, getToken,
postEventTerminateSession,
type DashboardSummary, type DashboardSummary,
type EventSummary, type EventSummary,
} from "../api"; } from "../api";
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
import RdgFlapBadge from "../components/RdgFlapBadge.vue"; import RdgFlapBadge from "../components/RdgFlapBadge.vue";
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue"; import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta"; import { useRdgQwinsta } from "../composables/useRdgQwinsta";
import { useSacVersion } from "../composables/useSacVersion";
import { formatServerName } from "../utils/hostDisplay"; import { formatServerName } from "../utils/hostDisplay";
import { hasRdgFlapUi, rdgFlapQwinstaEventId } from "../utils/rdgFlap"; import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const { appName, appVersion } = useSacVersion();
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta(); const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
const sessionTerminateLoadingId = ref<number | null>(null);
const data = ref<DashboardSummary | null>(null); const data = ref<DashboardSummary | null>(null);
@@ -366,6 +379,27 @@ function formatDt(iso: string) {
} }
async function runSessionTerminate(event: EventSummary) {
const label = event.actor_user || event.title;
if (!window.confirm(`Завершить сессию пользователя ${label}?`)) return;
sessionTerminateLoadingId.value = event.id;
try {
const res = await postEventTerminateSession(event.id);
if (!res.ok) {
window.alert(res.message);
} else {
markEventSessionTerminatedInList(data.value?.recent_events, event.id);
}
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
markEventSessionTerminatedInList(data.value?.recent_events, event.id);
}
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
} finally {
sessionTerminateLoadingId.value = null;
}
}
function syncLastKnownFromRecent() { function syncLastKnownFromRecent() {
@@ -603,12 +637,6 @@ onUnmounted(() => {
} }
.dashboard-sac-version {
margin: -0.35rem 0 1rem;
color: #9aa4b2;
font-size: 0.9rem;
}
.dash-recent-hint { .dash-recent-hint {
margin: -0.25rem 0 0.75rem; margin: -0.25rem 0 0.75rem;
+9 -3
View File
@@ -7,18 +7,23 @@
<p v-else-if="loading">Загрузка</p> <p v-else-if="loading">Загрузка</p>
<template v-else-if="event"> <template v-else-if="event">
<h1>{{ event.title }}</h1> <h1>{{ event.title }}</h1>
<p v-if="event.rdg_access_path" class="event-rdg-access-banner">
<RdgAccessBadge :event="event" />
<span class="muted">Вход на рабочий ПК через RD Gateway.</span>
</p>
<p v-if="hasRdgFlapUi(event)" class="event-rdg-flap-banner"> <p v-if="hasRdgFlapUi(event)" class="event-rdg-flap-banner">
<RdgFlapBadge :event="event" :detail-link-query="backToEventsQuery" /> <RdgFlapBadge :event="event" :detail-link-query="backToEventsQuery" />
<span class="muted">302303 за несколько секунд возможна зависшая сессия на ПК пользователя.</span> <span class="muted">302303 за несколько секунд возможна зависшая сессия на ПК пользователя.</span>
</p> </p>
<p v-if="rdgFlapQwinstaEventId(event)" class="event-rdg-actions"> <p v-if="rdgQwinstaEventId(event)" class="event-rdg-actions">
<button <button
type="button" type="button"
class="secondary" class="secondary"
:disabled="qwinstaLoadingId === event.id" :disabled="qwinstaLoadingId === event.id"
title="qwinsta и завершение сеанса на рабочем ПК"
@click="runQwinsta(event)" @click="runQwinsta(event)"
> >
qwinsta (RDG flap) Обрыв RDS
</button> </button>
</p> </p>
<RdgQwinstaModal <RdgQwinstaModal
@@ -93,11 +98,12 @@
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { apiFetch, type EventDetail } from "../api"; import { apiFetch, type EventDetail } from "../api";
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
import RdgFlapBadge from "../components/RdgFlapBadge.vue"; import RdgFlapBadge from "../components/RdgFlapBadge.vue";
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue"; import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import ReportBodyCard from "../components/ReportBodyCard.vue"; import ReportBodyCard from "../components/ReportBodyCard.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta"; import { useRdgQwinsta } from "../composables/useRdgQwinsta";
import { hasRdgFlapUi, rdgFlapQwinstaEventId } from "../utils/rdgFlap"; import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
import { eventsBackQueryFromDetail } from "../utils/eventsListQuery"; import { eventsBackQueryFromDetail } from "../utils/eventsListQuery";
import { dailyReportTypeLabel, isDailyReportType } from "../utils/reportDisplay"; import { dailyReportTypeLabel, isDailyReportType } from "../utils/reportDisplay";
+52 -6
View File
@@ -9,7 +9,7 @@
<option>high</option> <option>high</option>
<option>critical</option> <option>critical</option>
</select> </select>
<input v-model="typeFilter" placeholder="type" @keyup.enter="applyFilters" /> <input v-model="typeFilter" placeholder="type (префикс)" @keyup.enter="applyFilters" />
<input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="applyFilters" /> <input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="applyFilters" />
<label class="events-date-field"> <label class="events-date-field">
<span class="events-date-label">С</span> <span class="events-date-label">С</span>
@@ -54,18 +54,29 @@
<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>
<td> <td>
<RdgAccessBadge v-if="e.rdg_access_path" :event="e" />
<RdgFlapBadge v-if="hasRdgFlapUi(e)" :event="e" :detail-link-query="detailLinkQuery" /> <RdgFlapBadge v-if="hasRdgFlapUi(e)" :event="e" :detail-link-query="detailLinkQuery" />
{{ e.title }} {{ e.title }}
</td> </td>
<td class="events-actions"> <td class="events-actions">
<button <button
v-if="rdgFlapQwinstaEventId(e)" v-if="rdgQwinstaEventId(e)"
type="button" type="button"
class="secondary events-qwinsta-btn" class="secondary events-qwinsta-btn"
:disabled="qwinstaLoadingId === e.id" :disabled="qwinstaLoadingId === e.id"
title="qwinsta и завершение сеанса на рабочем ПК"
@click="runQwinsta(e)" @click="runQwinsta(e)"
> >
qwinsta Обрыв RDS
</button>
<button
v-if="showEventSessionTerminateButton(e)"
type="button"
class="secondary events-qwinsta-btn"
:disabled="sessionTerminateLoadingId === e.id"
@click="runSessionTerminate(e)"
>
{{ sessionTerminateLoadingId === e.id ? "…" : "Завершить" }}
</button> </button>
</td> </td>
</tr> </tr>
@@ -94,11 +105,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { apiFetch, type EventListResponse } from "../api"; import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
import { apiFetch, ApiError, postEventTerminateSession, type EventListResponse, type EventSummary } from "../api";
import RdgAccessBadge from "../components/RdgAccessBadge.vue";
import RdgFlapBadge from "../components/RdgFlapBadge.vue"; import RdgFlapBadge from "../components/RdgFlapBadge.vue";
import RdgQwinstaModal from "../components/RdgQwinstaModal.vue"; import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta"; import { useRdgQwinsta } from "../composables/useRdgQwinsta";
import { hasRdgFlapUi, rdgFlapQwinstaEventId } from "../utils/rdgFlap"; import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
import { import {
apiParamsFromState, apiParamsFromState,
buildEventsListQuery, buildEventsListQuery,
@@ -107,10 +120,12 @@ import {
type EventsListState, type EventsListState,
} from "../utils/eventsListQuery"; } from "../utils/eventsListQuery";
import { formatServerName } from "../utils/hostDisplay"; import { formatServerName } from "../utils/hostDisplay";
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta(); const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
const sessionTerminateLoadingId = ref<number | null>(null);
const data = ref<EventListResponse | null>(null); const data = ref<EventListResponse | null>(null);
const loading = ref(false); const loading = ref(false);
@@ -177,10 +192,12 @@ async function syncRouteQuery() {
} }
} }
async function load(p?: number, options?: { syncUrl?: boolean }) { async function load(p?: number, options?: { syncUrl?: boolean; silent?: boolean }) {
if (p !== undefined) page.value = p; if (p !== undefined) page.value = p;
if (!options?.silent) {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
}
try { try {
const params = apiParamsFromState(listState.value, pageSize); const params = apiParamsFromState(listState.value, pageSize);
data.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`); data.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
@@ -188,11 +205,40 @@ async function load(p?: number, options?: { syncUrl?: boolean }) {
await syncRouteQuery(); await syncRouteQuery();
} }
} catch (e) { } catch (e) {
if (!options?.silent) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки"; error.value = e instanceof Error ? e.message : "Ошибка загрузки";
}
} finally { } finally {
if (!options?.silent) {
loading.value = false; loading.value = false;
} }
} }
}
useSacLiveEventStream(() => {
void load(undefined, { syncUrl: false, silent: true });
});
async function runSessionTerminate(event: EventSummary) {
const label = event.actor_user || event.title;
if (!window.confirm(`Завершить сессию пользователя ${label}?`)) return;
sessionTerminateLoadingId.value = event.id;
try {
const res = await postEventTerminateSession(event.id);
if (!res.ok) {
window.alert(res.message);
} else {
markEventSessionTerminatedInList(data.value?.items, event.id);
}
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
markEventSessionTerminatedInList(data.value?.items, event.id);
}
window.alert(e instanceof Error ? e.message : "Ошибка завершения сессии");
} finally {
sessionTerminateLoadingId.value = null;
}
}
function applyFilters() { function applyFilters() {
load(1); load(1);
+122 -42
View File
@@ -21,8 +21,14 @@
<div class="detail-field"> <div class="detail-field">
<dt>Версия агента</dt> <dt>Версия агента</dt>
<dd> <dd>
<span :class="versionStatusClass">{{ host.product_version || "—" }}</span> <HostAgentVersionUpgrade
<span v-if="host.version_status === 'outdated'" class="muted version-hint"> (устарела)</span> v-if="host"
:host="host"
:git="agentGitVersions"
:updating="isHostRemoteActionActive(hostId)"
:outdated-highlight="host.version_status === 'outdated'"
@upgrade="runAgentUpgradeFromCell"
/>
<span v-if="host.pending_agent_update" class="pending-update-badge">обновление запрошено</span> <span v-if="host.pending_agent_update" class="pending-update-badge">обновление запрошено</span>
</dd> </dd>
</div> </div>
@@ -85,13 +91,13 @@
<button <button
type="button" type="button"
class="secondary" class="secondary"
:disabled="runningAgentFallback" :disabled="isHostRemoteActionActive(hostId)"
@click="runAgentFallback" @click="runAgentFallback"
> >
{{ runningAgentFallback ? "Обновление…" : "Обновить через WinRM" }} {{ isHostRemoteActionActive(hostId) ? "Обновление…" : "Обновить через WinRM" }}
</button> </button>
</div> </div>
<p v-if="agentControlMessage && !hostRemoteActionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p> <p v-if="agentControlMessage && !hasOpenRemoteActionLogForHost(hostId)" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
</div> </div>
<div v-if="isLinuxHost" class="host-actions"> <div v-if="isLinuxHost" class="host-actions">
<div class="host-actions-row"> <div class="host-actions-row">
@@ -113,10 +119,10 @@
<button <button
type="button" type="button"
class="secondary" class="secondary"
:disabled="updatingAgent" :disabled="isHostRemoteActionActive(hostId)"
@click="runAgentUpdate" @click="runAgentUpdate"
> >
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor (SSH)" }} {{ isHostRemoteActionActive(hostId) ? "Обновление…" : "Обновить ssh-monitor (SSH)" }}
</button> </button>
<button <button
type="button" type="button"
@@ -129,13 +135,13 @@
<button <button
type="button" type="button"
class="secondary" class="secondary"
:disabled="runningAgentFallback" :disabled="isHostRemoteActionActive(hostId)"
@click="runAgentFallback" @click="runAgentFallback"
> >
{{ runningAgentFallback ? "Fallback…" : "Fallback SSH" }} {{ isHostRemoteActionActive(hostId) ? "Fallback…" : "Fallback SSH" }}
</button> </button>
</div> </div>
<p v-if="agentControlMessage && !hostRemoteActionLog.open" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p> <p v-if="agentControlMessage && !hasOpenRemoteActionLogForHost(hostId)" :class="agentControlOk ? 'success' : 'error'">{{ agentControlMessage }}</p>
<div v-if="showSshTestFeedback && (sshTestMessage || sshTestOutput)" class="host-action-feedback"> <div v-if="showSshTestFeedback && (sshTestMessage || sshTestOutput)" class="host-action-feedback">
<p :class="sshTestOk ? 'success' : 'error'">{{ sshTestMessage }}</p> <p :class="sshTestOk ? 'success' : 'error'">{{ sshTestMessage }}</p>
<pre v-if="sshTestOutput" class="host-ssh-output">{{ sshTestOutput }}</pre> <pre v-if="sshTestOutput" class="host-ssh-output">{{ sshTestOutput }}</pre>
@@ -143,7 +149,8 @@
</div> </div>
</div> </div>
<section v-if="showAgentConfig" class="card host-agent-config"> <div v-if="showAgentConfig" class="host-config-sessions-row">
<section ref="agentConfigSectionRef" class="card host-agent-config">
<h2>Desired config (poll агента)</h2> <h2>Desired config (poll агента)</h2>
<p class="muted">Агент заберёт настройки при следующем poll. Revision: {{ host.agent_config_revision ?? 0 }}</p> <p class="muted">Агент заберёт настройки при следующем poll. Revision: {{ host.agent_config_revision ?? 0 }}</p>
<form class="agent-config-form" @submit.prevent="saveAgentConfig"> <form class="agent-config-form" @submit.prevent="saveAgentConfig">
@@ -166,6 +173,9 @@
</form> </form>
</section> </section>
<HostSessionsPanel :host-id="host.id" :height-source="agentConfigSectionRef" />
</div>
<section v-if="inventory" class="host-inventory card"> <section v-if="inventory" class="host-inventory card">
<h2>Железо и ПО</h2> <h2>Железо и ПО</h2>
<div v-if="windowsInfo" class="inv-block"> <div v-if="windowsInfo" class="inv-block">
@@ -279,7 +289,8 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream"; import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
import { import {
hostRemoteActionLog, hasOpenRemoteActionLogForHost,
isHostRemoteActionActive,
runHostRemoteAction, runHostRemoteAction,
} from "../composables/useHostRemoteAction"; } from "../composables/useHostRemoteAction";
import { import {
@@ -294,12 +305,27 @@ import {
type HostDetail, type HostDetail,
} from "../api"; } from "../api";
import { emitHostPatch } from "../utils/hostPatchBus"; import { emitHostPatch } from "../utils/hostPatchBus";
import HostSessionsPanel from "../components/HostSessionsPanel.vue";
import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue";
import {
patchHostDetailFromEvent,
prependHostEventIfMissing,
recalcVersionStatusFromGit,
} from "../utils/hostDetailLive";
import {
isLinuxAgentHost,
remoteActionKindForHost,
remoteActionTitleForHost,
type AgentGitVersions,
} from "../utils/hostAgentUpgrade";
import { fetchAgentUpdateSettings } from "../api";
const HOST_ACTION_FEEDBACK_MS = 30_000; const HOST_ACTION_FEEDBACK_MS = 30_000;
const props = defineProps<{ id: string }>(); const props = defineProps<{ id: string }>();
const route = useRoute(); const route = useRoute();
const agentConfigSectionRef = ref<HTMLElement | null>(null);
const host = ref<HostDetail | null>(null); const host = ref<HostDetail | null>(null);
const events = ref<EventListResponse | null>(null); const events = ref<EventListResponse | null>(null);
const loading = ref(false); const loading = ref(false);
@@ -313,13 +339,11 @@ const winRmTestMessage = ref("");
const winRmTestOk = ref(false); const winRmTestOk = ref(false);
const showWinRmTestFeedback = ref(false); const showWinRmTestFeedback = ref(false);
const testingSsh = ref(false); const testingSsh = ref(false);
const updatingAgent = ref(false);
const showSshTestFeedback = ref(false); const showSshTestFeedback = ref(false);
const sshTestMessage = ref(""); const sshTestMessage = ref("");
const sshTestOk = ref(false); const sshTestOk = ref(false);
const sshTestOutput = ref(""); const sshTestOutput = ref("");
const requestingAgentUpdate = ref(false); const requestingAgentUpdate = ref(false);
const runningAgentFallback = ref(false);
const agentControlMessage = ref(""); const agentControlMessage = ref("");
const agentControlOk = ref(false); const agentControlOk = ref(false);
const savingAgentConfig = ref(false); const savingAgentConfig = ref(false);
@@ -333,9 +357,13 @@ 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;
let refreshingLive = false; let refreshingLive = false;
let liveRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const LIVE_REFRESH_DEBOUNCE_MS = 400;
const agentGitVersions = ref<AgentGitVersions | null>(null);
useSacLiveEventStream(() => { useSacLiveEventStream(() => {
void refreshFromLatestEvent(); scheduleLiveRefreshFromEvent();
}); });
const hostId = computed(() => parseInt(props.id, 10)); const hostId = computed(() => parseInt(props.id, 10));
@@ -364,15 +392,12 @@ const sshVerifiedTitle = computed(() => {
return "SSH доступен"; return "SSH доступен";
}); });
const versionStatusClass = computed(() => {
const status = host.value?.version_status;
if (status === "outdated") return "agent-version-outdated";
if (status === "ok") return "agent-version-ok";
return "";
});
const showAgentConfig = computed(() => isWindowsHost.value || isLinuxHost.value); const showAgentConfig = computed(() => isWindowsHost.value || isLinuxHost.value);
function isLinuxHostDetail(detail: HostDetail): boolean {
return isLinuxAgentHost(detail);
}
function syncConfigFormFromHost(detail: HostDetail) { function syncConfigFormFromHost(detail: HostDetail) {
const cfg = detail.agent_config ?? {}; const cfg = detail.agent_config ?? {};
configForm.serverDisplayName = String( configForm.serverDisplayName = String(
@@ -463,11 +488,6 @@ function clearSshTestFeedbackTimer() {
} }
} }
function isLinuxHostDetail(detail: HostDetail): boolean {
if ((detail.os_family || "").toLowerCase() === "linux") return true;
return detail.product === "ssh-monitor";
}
function applySshAdminStatus(result: { ok: boolean; ssh_admin_ok?: boolean | null }) { function applySshAdminStatus(result: { ok: boolean; ssh_admin_ok?: boolean | null }) {
if (!host.value) return; if (!host.value) return;
host.value = { host.value = {
@@ -616,10 +636,8 @@ async function runRequestAgentUpdate() {
} }
async function runAgentFallback() { async function runAgentFallback() {
runningAgentFallback.value = true;
agentControlMessage.value = ""; agentControlMessage.value = "";
const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)"; const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)";
try {
const job = await runHostRemoteAction(hostId.value, title, "fallback"); const job = await runHostRemoteAction(hostId.value, title, "fallback");
if (job) { if (job) {
agentControlOk.value = job.ok ?? false; agentControlOk.value = job.ok ?? false;
@@ -630,9 +648,6 @@ async function runAgentFallback() {
agentControlMessage.value = job.message || ""; agentControlMessage.value = job.message || "";
} }
} }
} finally {
runningAgentFallback.value = false;
}
} }
async function saveAgentConfig() { async function saveAgentConfig() {
@@ -687,8 +702,6 @@ async function loadHost() {
} }
async function runAgentUpdate() { async function runAgentUpdate() {
updatingAgent.value = true;
try {
const job = await runHostRemoteAction( const job = await runHostRemoteAction(
hostId.value, hostId.value,
"Обновление ssh-monitor (SSH)", "Обновление ssh-monitor (SSH)",
@@ -700,23 +713,58 @@ async function runAgentUpdate() {
detail.product_version = job.product_version; detail.product_version = job.product_version;
} }
applyHostDetail(detail); applyHostDetail(detail);
} recalcVersionStatusFromGit(detail, agentGitVersions.value);
} finally {
updatingAgent.value = false;
} }
} }
async function runAgentUpgradeFromCell() {
if (!host.value) return;
const kind = remoteActionKindForHost(host.value);
if (!kind) return;
if (isHostRemoteActionActive(hostId.value)) {
return;
}
const job = await runHostRemoteAction(
hostId.value,
remoteActionTitleForHost(host.value),
kind,
);
if (job?.ok) {
const detail = await fetchHost(hostId.value);
if (job.product_version) {
detail.product_version = job.product_version;
}
applyHostDetail(detail);
recalcVersionStatusFromGit(detail, agentGitVersions.value);
}
}
function clearLiveRefreshTimer() {
if (liveRefreshTimer != null) {
clearTimeout(liveRefreshTimer);
liveRefreshTimer = null;
}
}
function scheduleLiveRefreshFromEvent() {
clearLiveRefreshTimer();
liveRefreshTimer = setTimeout(() => {
liveRefreshTimer = null;
void refreshFromLatestEvent();
}, LIVE_REFRESH_DEBOUNCE_MS);
}
async function refreshFromLatestEvent() { async function refreshFromLatestEvent() {
if (refreshingLive) return; if (refreshingLive || !host.value) return;
refreshingLive = true; refreshingLive = true;
try { try {
const recent = await fetchRecentEvents(1); const recent = await fetchRecentEvents(1);
const ev = recent[0]; const ev = recent[0];
if (!ev || ev.host_id !== hostId.value) return; if (!ev || ev.host_id !== hostId.value) return;
applyHostDetail(await fetchHost(hostId.value)); patchHostDetailFromEvent(host.value, ev);
if (eventsPage.value === 1) { recalcVersionStatusFromGit(host.value, agentGitVersions.value);
await loadEvents(1); emitHostPatch(host.value);
} prependHostEventIfMissing(events.value, ev, eventsPage.value);
} catch { } catch {
/* ignore transient errors */ /* ignore transient errors */
} finally { } finally {
@@ -724,6 +772,21 @@ async function refreshFromLatestEvent() {
} }
} }
async function loadAgentGitVersions() {
try {
const settings = await fetchAgentUpdateSettings();
agentGitVersions.value = {
git_latest_rdp_version: settings.git_latest_rdp_version,
git_latest_ssh_version: settings.git_latest_ssh_version,
};
if (host.value) {
recalcVersionStatusFromGit(host.value, agentGitVersions.value);
}
} catch {
agentGitVersions.value = null;
}
}
async function loadEvents(page = 1) { async function loadEvents(page = 1) {
eventsLoading.value = true; eventsLoading.value = true;
eventsError.value = ""; eventsError.value = "";
@@ -733,6 +796,7 @@ async function loadEvents(page = 1) {
page: String(page), page: String(page),
page_size: String(eventsPageSize), page_size: String(eventsPageSize),
host_id: String(hostId.value), host_id: String(hostId.value),
include_hidden: "true",
}); });
events.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`); events.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
} catch (e) { } catch (e) {
@@ -743,6 +807,7 @@ async function loadEvents(page = 1) {
} }
onMounted(async () => { onMounted(async () => {
void loadAgentGitVersions();
await loadHost(); await loadHost();
await loadEvents(1); await loadEvents(1);
}); });
@@ -750,6 +815,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
clearSshTestFeedbackTimer(); clearSshTestFeedbackTimer();
clearWinRmTestFeedbackTimer(); clearWinRmTestFeedbackTimer();
clearLiveRefreshTimer();
}); });
watch( watch(
@@ -844,7 +910,21 @@ watch(
} }
.host-agent-config { .host-agent-config {
margin-top: 0;
}
.host-config-sessions-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1rem; margin-top: 1rem;
align-items: flex-start;
}
.host-config-sessions-row > .host-agent-config,
.host-config-sessions-row > .host-sessions {
flex: 1 1 22rem;
min-width: min(100%, 22rem);
} }
.agent-config-form { .agent-config-form {
+115 -18
View File
@@ -15,14 +15,29 @@
</select> </select>
</label> </label>
<button type="button" class="secondary" @click="applyFilters">Найти</button> <button type="button" class="secondary" @click="applyFilters">Найти</button>
<button type="button" @click="manualAddOpen = true">Добавить вручную</button>
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p> <p v-else-if="loading">Загрузка</p>
<template v-else> <template v-else>
<p> <div class="hosts-list-meta">
<p class="hosts-list-total">
Всего: {{ data?.total ?? 0 }} Всего: {{ data?.total ?? 0 }}
<span v-if="live" class="live-badge">live</span> <span v-if="live" class="live-badge">live</span>
</p> </p>
<aside
class="agent-releases-plate"
title="Версии из git-репозиториев агентов (version.txt, ветка main)"
>
<span class="agent-releases-plate__label">Последние версии агентов:</span>
<span class="agent-releases-plate__item">
Win: <code>{{ gitWinVersion }}</code>
</span>
<span class="agent-releases-plate__item">
SSH: <code>{{ gitSshVersion }}</code>
</span>
</aside>
</div>
<table class="hosts-table"> <table class="hosts-table">
<thead> <thead>
<tr> <tr>
@@ -53,13 +68,14 @@
<td>{{ h.id }}</td> <td>{{ h.id }}</td>
<td>{{ h.display_name || h.hostname }}</td> <td>{{ h.display_name || h.hostname }}</td>
<td>{{ h.hostname }}</td> <td>{{ h.hostname }}</td>
<td> <td @click.stop>
<span <HostAgentVersionUpgrade
:class="{ 'agent-version-outdated': isVersionOutdated(h) }" :host="h"
:title="versionTitle(h)" :git="data"
> :updating="isHostRemoteActionActive(h.id)"
{{ h.product_version || "—" }} :outdated-highlight="isVersionOutdated(h)"
</span> @upgrade="startAgentUpgrade(h)"
/>
</td> </td>
<td>{{ h.os_family }}</td> <td>{{ h.os_family }}</td>
<td>{{ h.ipv4 || "—" }}</td> <td>{{ h.ipv4 || "—" }}</td>
@@ -93,6 +109,11 @@
</tbody> </tbody>
</table> </table>
</template> </template>
<HostManualAddModal
:open="manualAddOpen"
@close="manualAddOpen = false"
@started="onManualAddStarted"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -111,6 +132,19 @@ import {
import { bumpLatestAgentVersion, patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate"; import { bumpLatestAgentVersion, patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate";
import { subscribeHostPatch } from "../utils/hostPatchBus"; import { subscribeHostPatch } from "../utils/hostPatchBus";
import { isAgentVersionOutdated } from "../utils/agentVersion"; import { isAgentVersionOutdated } from "../utils/agentVersion";
import {
isGitAgentUpgradeAvailable,
remoteActionKindForHost,
remoteActionTitleForHost,
} from "../utils/hostAgentUpgrade";
import {
isHostRemoteActionActive,
pollHostRemoteAction,
runHostRemoteAction,
showHostRemoteActionLog,
} from "../composables/useHostRemoteAction";
import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue";
import HostManualAddModal from "../components/HostManualAddModal.vue";
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -174,6 +208,7 @@ const initialSort = loadHostsSortPrefs();
const sortBy = ref<SortKey>(initialSort.sortBy); const sortBy = ref<SortKey>(initialSort.sortBy);
const sortDir = ref<"asc" | "desc">(initialSort.sortDir); const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
const deletingId = ref<number | null>(null); const deletingId = ref<number | null>(null);
const manualAddOpen = ref(false);
let patchingHostRow = false; let patchingHostRow = false;
let unsubscribeHostPatch: (() => void) | null = null; let unsubscribeHostPatch: (() => void) | null = null;
@@ -209,17 +244,17 @@ function isVersionOutdated(h: HostSummary): boolean {
return isAgentVersionOutdated(h.product_version, reference); return isAgentVersionOutdated(h.product_version, reference);
} }
function versionTitle(h: HostSummary): string { async function startAgentUpgrade(h: HostSummary) {
const reference = if (!isGitAgentUpgradeAvailable(h, data.value)) {
data.value?.reference_agent_versions?.[h.product] ?? return;
data.value?.latest_agent_versions?.[h.product];
if (!isVersionOutdated(h)) return "";
if (!reference) return "Устаревшая версия агента";
const fromSettings = data.value?.reference_agent_versions?.[h.product];
if (fromSettings && fromSettings !== data.value?.latest_agent_versions?.[h.product]) {
return `Устарела относительно эталона ${reference} (настройки SAC)`;
} }
return `Устарела относительно эталона ${reference}`; if (isHostRemoteActionActive(h.id)) {
showHostRemoteActionLog(h.id);
return;
}
const kind = remoteActionKindForHost(h);
if (!kind) return;
void runHostRemoteAction(h.id, remoteActionTitleForHost(h), kind);
} }
function openHost(id: number) { function openHost(id: number) {
@@ -242,6 +277,13 @@ function setSort(key: SortKey) {
saveHostsSortPrefs(sortBy.value, sortDir.value); saveHostsSortPrefs(sortBy.value, sortDir.value);
} }
const gitWinVersion = computed(
() => data.value?.git_latest_rdp_version?.trim() || "—",
);
const gitSshVersion = computed(
() => data.value?.git_latest_ssh_version?.trim() || "—",
);
function sortMark(key: SortKey) { function sortMark(key: SortKey) {
if (sortBy.value !== key) return ""; if (sortBy.value !== key) return "";
return sortDir.value === "asc" ? "↑" : "↓"; return sortDir.value === "asc" ? "↑" : "↓";
@@ -371,6 +413,11 @@ interface HostDeleteResponse {
deleted_problems: number; deleted_problems: number;
} }
async function onManualAddStarted(payload: { hostId: number; title: string }) {
await loadHosts();
void pollHostRemoteAction(payload.hostId, payload.title);
}
async function confirmDelete(h: HostSummary) { async function confirmDelete(h: HostSummary) {
const label = h.display_name || h.hostname; const label = h.display_name || h.hostname;
const n = h.event_count ?? 0; const n = h.event_count ?? 0;
@@ -412,3 +459,53 @@ watch(
}, },
); );
</script> </script>
<style scoped>
.hosts-list-meta {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 0.5rem 0.75rem;
margin: 0.35rem 0 1rem;
}
.hosts-list-total {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
line-height: 1.5;
}
.agent-releases-plate {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.85rem;
margin: -0.15rem 0 0;
padding: 0.45rem 1rem 0.5rem 1.1rem;
border-radius: 0 0.75rem 0.75rem 0.35rem;
border: 1px solid #2d3a4d;
border-left: 3px solid #58a6ff;
background: linear-gradient(135deg, #1a2332 0%, #151d28 100%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.35),
0 1px 0 rgba(88, 166, 255, 0.12) inset;
font-size: 0.9rem;
color: #9aa4b2;
transform: translateY(-1px);
}
.agent-releases-plate__label {
color: #c9d1d9;
font-weight: 500;
}
.agent-releases-plate__item code {
font-size: 0.88em;
color: #79c0ff;
background: rgba(88, 166, 255, 0.08);
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
}
</style>
+237 -1
View File
@@ -44,6 +44,85 @@
</form> </form>
</section> </section>
<section class="card settings-card settings-policy">
<h2>Блокировки входа (UI и SSH)</h2>
<p class="settings-hint settings-hint-top">
<strong>Web UI:</strong> SAC блокирует IP после {{ loginSecurityLoaded?.max_failures ?? 3 }} неудачных
попыток входа за {{ loginSecurityLoaded?.window_minutes ?? 15 }} мин (таблица
<code>login_attempts</code>, ответ HTTP 429).
<strong> SSH</strong> блокирует <code>fail2ban</code> на ОС это отдельно от SAC; типичная причина
неверный пароль при <code>ssh</code>, не путать с паролем UI.
IP из белого списка не учитываются в лимите UI и могут синхронизироваться в fail2ban
(<code>ignoreip</code>), если включена опция ниже.
</p>
<form class="settings-form" @submit.prevent="saveLoginSecuritySettingsHandler">
<label class="settings-field">
Белый список IP (по одному на строку)
<textarea
v-model="loginSecurityForm.ip_whitelist_text"
rows="5"
placeholder="192.168.160.3&#10;192.168.160.42"
spellcheck="false"
/>
</label>
<label class="settings-field settings-inline">
<input v-model="loginSecurityForm.sync_fail2ban" type="checkbox" />
Синхронизировать белый список в fail2ban ignoreip (SSH)
</label>
<p v-if="loginSecurityLoaded" class="settings-meta">
fail2ban: {{ loginSecurityLoaded.fail2ban_available ? "доступен" : "не найден на сервере" }}
· источник whitelist: <code>{{ loginSecurityLoaded.source }}</code>
· лимит UI: {{ loginSecurityLoaded.max_failures }} /
{{ loginSecurityLoaded.window_minutes }} мин
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingLoginSecurity">
{{ savingLoginSecurity ? "Сохранение…" : "Сохранить белый список" }}
</button>
<button type="button" class="secondary" :disabled="loadingLoginBlocks" @click="refreshLoginBlocks">
{{ loadingLoginBlocks ? "Обновление…" : "Обновить список блокировок" }}
</button>
</div>
</form>
<h3>Заблокированные IP</h3>
<p v-if="loadingLoginBlocks" class="settings-meta">Загрузка</p>
<p v-else-if="!loginBlocks.length" class="settings-meta">Сейчас блокировок нет (или все IP в белом списке).</p>
<table v-else class="settings-blocks-table">
<thead>
<tr>
<th>IP</th>
<th>Где</th>
<th>Попытки / причина</th>
<th>Логины</th>
<th>До</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in loginBlocks" :key="row.ip_address + row.scope">
<td><code>{{ row.ip_address }}</code></td>
<td>{{ row.scope }}</td>
<td>
<span v-if="row.failure_count != null">{{ row.failure_count }} · </span>{{ row.reason }}
</td>
<td>{{ row.usernames.length ? row.usernames.join(", ") : "—" }}</td>
<td>{{ row.blocked_until ? formatDateTime(row.blocked_until) : "—" }}</td>
<td>
<button
type="button"
class="secondary"
:disabled="unblockingIp === row.ip_address"
@click="unblockIp(row.ip_address, row.scope)"
>
{{ unblockingIp === row.ip_address ? "…" : "Разблокировать" }}
</button>
</td>
</tr>
</tbody>
</table>
</section>
<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">
@@ -80,6 +159,28 @@
</button> </button>
</div> </div>
</form> </form>
<form class="settings-form" @submit.prevent="saveRdpFlapSettings">
<h3>RDP flapping</h3>
<p class="settings-hint">
При включении SAC автоматически завершает зависшую сессию пользователя на целевом ПК
(WinRM qwinsta/logoff): при RDG flap 302303 и при прямом RDP, если вход не удался изза
открытой сессии. Нужен настроенный доменный admin выше.
</p>
<label class="settings-field settings-inline">
<input v-model="rdpFlapForm.auto_disconnect" type="checkbox" />
Автоматически определять RDP flapping и отключать сессии
</label>
<p v-if="rdpFlapLoaded" class="settings-meta">
Windows admin: {{ rdpFlapLoaded.win_admin_configured ? "настроен" : "не настроен" }}
· источник: <code>{{ rdpFlapLoaded.source }}</code>
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingRdpFlap">
{{ savingRdpFlap ? "Сохранение…" : "Сохранить RDP flapping" }}
</button>
</div>
</form>
</section> </section>
<section class="card settings-card settings-policy"> <section class="card settings-card settings-policy">
@@ -602,10 +703,18 @@ import {
fetchMobileSettings, fetchMobileSettings,
fetchUsers, fetchUsers,
fetchWinAdminSettings, fetchWinAdminSettings,
fetchRdpFlapSettings,
updateRdpFlapSettings,
fetchLinuxAdminSettings, fetchLinuxAdminSettings,
fetchAgentUpdateSettings, fetchAgentUpdateSettings,
saveAgentUpdateSettings, saveAgentUpdateSettings,
testAgentGitRelease, testAgentGitRelease,
fetchLoginSecuritySettings,
saveLoginSecuritySettings,
fetchLoginSecurityBlocks,
unblockLoginSecurityIp,
type LoginBlockItem,
type LoginSecuritySettings,
revokeMobileDevice, revokeMobileDevice,
deleteMobileDevice, deleteMobileDevice,
revokeMobileEnrollmentCode, revokeMobileEnrollmentCode,
@@ -617,6 +726,7 @@ import {
type MobileEnrollmentCode, type MobileEnrollmentCode,
type MobileSettings, type MobileSettings,
type AgentUpdateSettings, type AgentUpdateSettings,
type RdpFlapSettings,
type UserSummary, type UserSummary,
} from "../api"; } from "../api";
@@ -700,6 +810,9 @@ const savingUi = ref(false);
const savingWinAdmin = ref(false); const savingWinAdmin = ref(false);
const savingLinuxAdmin = ref(false); const savingLinuxAdmin = ref(false);
const savingAgentUpdate = ref(false); const savingAgentUpdate = ref(false);
const savingLoginSecurity = ref(false);
const loadingLoginBlocks = ref(false);
const unblockingIp = ref("");
const testingAgentGit = ref(false); const testingAgentGit = ref(false);
const agentGitTestErrors = ref(""); const agentGitTestErrors = ref("");
const agentGitShowVersions = ref(false); const agentGitShowVersions = ref(false);
@@ -723,8 +836,12 @@ const emailLoaded = ref<EmailSettings | null>(null);
const policyLoaded = ref<NotificationPolicy | null>(null); const policyLoaded = ref<NotificationPolicy | null>(null);
const uiLoaded = ref<UiSettings | null>(null); const uiLoaded = ref<UiSettings | null>(null);
const winAdminLoaded = ref<WinAdminSettings | null>(null); const winAdminLoaded = ref<WinAdminSettings | null>(null);
const rdpFlapLoaded = ref<RdpFlapSettings | null>(null);
const savingRdpFlap = ref(false);
const linuxAdminLoaded = ref<LinuxAdminSettings | null>(null); const linuxAdminLoaded = ref<LinuxAdminSettings | null>(null);
const agentUpdateLoaded = ref<AgentUpdateSettings | null>(null); const agentUpdateLoaded = ref<AgentUpdateSettings | null>(null);
const loginSecurityLoaded = ref<LoginSecuritySettings | null>(null);
const loginBlocks = ref<LoginBlockItem[]>([]);
const severityRows = ref<SeverityOverrideRowUi[]>([]); const severityRows = ref<SeverityOverrideRowUi[]>([]);
const severityFilter = ref(""); const severityFilter = ref("");
const mobileLoaded = ref<MobileSettings | null>(null); const mobileLoaded = ref<MobileSettings | null>(null);
@@ -787,11 +904,20 @@ const uiForm = reactive({
show_sidebar_system_stats: true, show_sidebar_system_stats: true,
}); });
const loginSecurityForm = reactive({
ip_whitelist_text: "",
sync_fail2ban: false,
});
const winAdminForm = reactive({ const winAdminForm = reactive({
user: "", user: "",
password: "", password: "",
}); });
const rdpFlapForm = reactive({
auto_disconnect: false,
});
const linuxAdminForm = reactive({ const linuxAdminForm = reactive({
user: "", user: "",
password: "", password: "",
@@ -926,6 +1052,67 @@ async function saveSeverityOverrides() {
} }
} }
async function loadLoginSecuritySettings() {
loginSecurityLoaded.value = await fetchLoginSecuritySettings();
loginSecurityForm.ip_whitelist_text = loginSecurityLoaded.value.ip_whitelist_text ?? "";
loginSecurityForm.sync_fail2ban = loginSecurityLoaded.value.sync_fail2ban;
await refreshLoginBlocks();
}
async function saveLoginSecuritySettingsHandler() {
savingLoginSecurity.value = true;
error.value = "";
success.value = "";
try {
loginSecurityLoaded.value = await saveLoginSecuritySettings({
ip_whitelist_text: loginSecurityForm.ip_whitelist_text,
sync_fail2ban: loginSecurityForm.sync_fail2ban,
});
success.value = "Белый список IP сохранён.";
await refreshLoginBlocks();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения login-security";
} finally {
savingLoginSecurity.value = false;
}
}
async function refreshLoginBlocks() {
loadingLoginBlocks.value = true;
try {
loginBlocks.value = await fetchLoginSecurityBlocks();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки блокировок";
} finally {
loadingLoginBlocks.value = false;
}
}
function unblockScopeFromRow(scope: string): "web" | "ssh" | "both" {
const s = scope.toLowerCase();
if (s.includes("web") && s.includes("ssh")) return "both";
if (s.includes("ssh")) return "ssh";
return "web";
}
async function unblockIp(ip: string, scope: string) {
unblockingIp.value = ip;
error.value = "";
success.value = "";
try {
const result = await unblockLoginSecurityIp({
ip_address: ip,
scope: unblockScopeFromRow(scope),
});
success.value = result.messages.join(" · ");
await refreshLoginBlocks();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка разблокировки";
} finally {
unblockingIp.value = "";
}
}
async function loadUiSettings() { async function loadUiSettings() {
const data = await apiFetch<UiSettings>("/api/v1/settings/ui"); const data = await apiFetch<UiSettings>("/api/v1/settings/ui");
uiLoaded.value = data; uiLoaded.value = data;
@@ -958,6 +1145,28 @@ async function loadWinAdminSettings() {
winAdminForm.password = ""; winAdminForm.password = "";
} }
async function loadRdpFlapSettings() {
const data = await fetchRdpFlapSettings();
rdpFlapLoaded.value = data;
rdpFlapForm.auto_disconnect = data.auto_disconnect;
}
async function saveRdpFlapSettings() {
savingRdpFlap.value = true;
error.value = "";
success.value = "";
try {
rdpFlapLoaded.value = await updateRdpFlapSettings({
auto_disconnect: rdpFlapForm.auto_disconnect,
});
success.value = "Настройки RDP flapping сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения RDP flapping";
} finally {
savingRdpFlap.value = false;
}
}
async function saveWinAdminSettings() { async function saveWinAdminSettings() {
savingWinAdmin.value = true; savingWinAdmin.value = true;
error.value = ""; error.value = "";
@@ -1270,7 +1479,9 @@ async function load() {
emailForm.smtp_starttls = data.email.smtp_starttls; emailForm.smtp_starttls = data.email.smtp_starttls;
emailForm.smtp_ssl = data.email.smtp_ssl; emailForm.smtp_ssl = data.email.smtp_ssl;
await loadUiSettings(); await loadUiSettings();
await loadLoginSecuritySettings();
await loadWinAdminSettings(); await loadWinAdminSettings();
await loadRdpFlapSettings();
await loadLinuxAdminSettings(); await loadLinuxAdminSettings();
await loadAgentUpdateSettings(); await loadAgentUpdateSettings();
await loadSeverityOverrides(); await loadSeverityOverrides();
@@ -1460,6 +1671,10 @@ onMounted(() => {
margin-top: 0; margin-top: 0;
} }
.settings-card h3 {
margin-top: 1.25rem;
}
.settings-form { .settings-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1476,10 +1691,31 @@ onMounted(() => {
.settings-field input[type="text"], .settings-field input[type="text"],
.settings-field input[type="password"], .settings-field input[type="password"],
.settings-field input[type="url"], .settings-field input[type="url"],
.settings-field select { .settings-field select,
.settings-field textarea {
max-width: 28rem; max-width: 28rem;
} }
.settings-blocks-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.settings-blocks-table th,
.settings-blocks-table td {
border: 1px solid #3d4f66;
padding: 0.35rem 0.5rem;
text-align: left;
vertical-align: top;
}
.settings-blocks-table th {
color: #9aa4b2;
font-weight: 600;
}
.settings-policy { .settings-policy {
border-color: #3d4f66; border-color: #3d4f66;
} }