Compare commits
34 Commits
d96d9d6333
...
939fe122c5
| Author | SHA1 | Date | |
|---|---|---|---|
| 939fe122c5 | |||
| c72e510fb0 | |||
| 6c43b8637e | |||
| 3765d4d476 | |||
| 10ae670962 | |||
| daeb7e965d | |||
| 606bf53019 | |||
| fd1be5e7e4 | |||
| 5a7909bb55 | |||
| 86fd400f0b | |||
| dd2ae51b43 | |||
| 719d5c5719 | |||
| bda02f67eb | |||
| 9053ef0588 | |||
| 0601aafd2d | |||
| 8b3e43f30d | |||
| 6330f31162 | |||
| b47cbf03c5 | |||
| 407ef37004 | |||
| 64b942447b | |||
| 8b52c5fe3c | |||
| fbd213f323 | |||
| 1b96a3b2c7 | |||
| 24ca87a47a | |||
| 4eb41608ea | |||
| 6e3f0ede4d | |||
| fe818e9946 | |||
| 5e26834e16 | |||
| 54c1d96933 | |||
| a51a585b14 | |||
| fc66b57e78 | |||
| ad2425b0ff | |||
| d848d94604 | |||
| ca0255e13b |
@@ -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 за 1–10 с)
|
- **Problems** — автокорреляция (в т.ч. RDG 302→303 flap за 1–10 с)
|
||||||
- **Оповещения** — 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
@@ -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 1–10 s)
|
- **Problems** — auto-correlation (incl. RDG 302→303 flap within 1–10 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")
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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})")
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,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
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,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}"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Generated
+8
-20
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 function hasActiveHostRemoteAction(): boolean {
|
export async function runHostRemoteAction(
|
||||||
return hostRemoteActionLog.loading;
|
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 {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">302→303 за несколько секунд — возможна зависшая сессия на ПК пользователя.</span>
|
<span class="muted">302→303 за несколько секунд — возможна зависшая сессия на ПК пользователя.</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";
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +205,39 @@ 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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 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 302→303 и при прямом 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user