Compare commits
1 Commits
d96d9d6333
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ed4e78f6c3 |
@@ -0,0 +1,2 @@
|
||||
# Shell scripts must use LF — CRLF in shebang breaks systemd (status=203/EXEC).
|
||||
*.sh text eol=lf
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
| Репозиторий | Назначение |
|
||||
|-------------|------------|
|
||||
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux-агент |
|
||||
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows-агент |
|
||||
| [ssh-monitor](https://git.papatramp.ru/PapaTramp/ssh-monitor) | Linux-агент |
|
||||
| [RDP-login-monitor](https://git.papatramp.ru/PapaTramp/RDP-login-monitor) | Windows-агент |
|
||||
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
|
||||
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
|
||||
| [seaca](https://git.papatramp.ru/PapaTramp/seaca) | Android-клиент |
|
||||
|
||||
**Версия:** `0.20.18` · **Деплой:** `sudo /opt/sac-deploy.sh`
|
||||
**Версия:** `0.5.16` · **Деплой:** `sudo /opt/sac-deploy.sh`
|
||||
|
||||
## Возможности
|
||||
|
||||
@@ -21,10 +21,11 @@
|
||||
- **Problems** — автокорреляция (в т.ч. RDG 302→303 flap за 1–10 с)
|
||||
- **Оповещения** — Telegram, email, webhook, FCM (Seaca); severity + cooldown
|
||||
- **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 на ПК не нужен)
|
||||
- **Мобильные** — enrollment, устройства, push; Seaca: ack/resolve, qwinsta/logoff через API SAC
|
||||
- **Роли** — `admin` / `monitor`; JWT, rate limit входа
|
||||
- **Безопасность (0.5.0)** — проверка host key SSH, SSE через httpOnly-cookie, защита rate limit от спуфинга `X-Forwarded-For`, `SAC_SECURITY_ENFORCE`, HMAC для API-ключей
|
||||
|
||||
## Документация
|
||||
|
||||
@@ -39,8 +40,8 @@
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
# deploy/env.native.example → config/sac-api.env
|
||||
git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
# deploy/env.native.example → config/sac-api.env (JWT_SECRET, CORS_ORIGINS, SAC_SECURITY_ENFORCE)
|
||||
cd /opt/security-alert-center/backend && python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
|
||||
```
|
||||
|
||||
+8
-7
@@ -8,12 +8,12 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre
|
||||
|
||||
| Repository | Role |
|
||||
|------------|------|
|
||||
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux agent |
|
||||
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows agent |
|
||||
| [ssh-monitor](https://git.papatramp.ru/PapaTramp/ssh-monitor) | Linux agent |
|
||||
| [RDP-login-monitor](https://git.papatramp.ru/PapaTramp/RDP-login-monitor) | Windows agent |
|
||||
| **security-alert-center** | SAC server (Ubuntu 24.04) |
|
||||
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client |
|
||||
| [seaca](https://git.papatramp.ru/PapaTramp/seaca) | Android client |
|
||||
|
||||
**Version:** `0.20.18` · **Deploy:** `sudo /opt/sac-deploy.sh`
|
||||
**Version:** `0.5.16` · **Deploy:** `sudo /opt/sac-deploy.sh`
|
||||
|
||||
## Features
|
||||
|
||||
@@ -21,10 +21,11 @@ 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)
|
||||
- **Notifications** — Telegram, email, webhook, FCM (Seaca)
|
||||
- **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)
|
||||
- **Mobile** — enrollment, devices, push; Seaca: ack/resolve, qwinsta/logoff via SAC API
|
||||
- **Roles** — `admin` / `monitor`
|
||||
- **Security (0.5.0)** — SSH host-key verification, SSE via httpOnly cookie, anti-spoof login rate limit, `SAC_SECURITY_ENFORCE`, HMAC API keys
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -38,8 +39,8 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
# deploy/env.native.example -> config/sac-api.env
|
||||
git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
# deploy/env.native.example → config/sac-api.env
|
||||
cd /opt/security-alert-center/backend && python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""per-host management credentials (SSH / WinRM override)
|
||||
|
||||
Revision ID: 027_host_mgmt_credentials
|
||||
Revises: 026_host_silence_dedupe
|
||||
Create Date: 2026-07-14
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "027_host_mgmt_credentials"
|
||||
down_revision = "026_host_silence_dedupe"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("hosts", sa.Column("mgmt_user", sa.String(length=256), nullable=True))
|
||||
op.add_column("hosts", sa.Column("mgmt_password", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("hosts", "mgmt_password")
|
||||
op.drop_column("hosts", "mgmt_user")
|
||||
+16
-82
@@ -1,156 +1,90 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
|
||||
from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
|
||||
|
||||
from app.auth.stream_cookie import clear_stream_auth_cookie, set_stream_auth_cookie
|
||||
from app.config import get_settings
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
from app.services.login_rate_limit import (
|
||||
|
||||
ensure_login_allowed,
|
||||
|
||||
record_login_failure,
|
||||
|
||||
record_login_success,
|
||||
|
||||
)
|
||||
|
||||
from app.services.user_auth import authenticate_user
|
||||
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
|
||||
username: str
|
||||
|
||||
password: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
|
||||
access_token: str
|
||||
|
||||
token_type: str = "bearer"
|
||||
|
||||
username: str
|
||||
|
||||
role: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
|
||||
username: str
|
||||
|
||||
role: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
|
||||
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> TokenResponse:
|
||||
|
||||
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> JSONResponse:
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.sac_admin_password and not _has_any_user(db):
|
||||
|
||||
raise HTTPException(
|
||||
|
||||
status_code=503,
|
||||
|
||||
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
ip_address = ensure_login_allowed(db, request)
|
||||
|
||||
|
||||
|
||||
user = authenticate_user(db, body.username, body.password)
|
||||
|
||||
if user is None:
|
||||
|
||||
record_login_failure(db, ip_address=ip_address, username=body.username)
|
||||
|
||||
db.commit()
|
||||
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
|
||||
|
||||
record_login_success(db, ip_address=ip_address, username=user.username)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
token = create_access_token(user.username, user.role)
|
||||
|
||||
return TokenResponse(
|
||||
|
||||
payload = TokenResponse(
|
||||
access_token=token,
|
||||
|
||||
username=user.username,
|
||||
|
||||
role=user.role,
|
||||
|
||||
)
|
||||
response = JSONResponse(content=payload.model_dump())
|
||||
set_stream_auth_cookie(response, token, settings)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
def logout() -> Response:
|
||||
settings = get_settings()
|
||||
response = Response(status_code=204)
|
||||
clear_stream_auth_cookie(response, settings)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
|
||||
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
|
||||
|
||||
return MeResponse(username=current_user.username, role=current_user.role)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _has_any_user(db: Session) -> bool:
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
count = db.scalar(select(func.count()).select_from(User)) or 0
|
||||
|
||||
return count > 0
|
||||
|
||||
except Exception:
|
||||
|
||||
db.rollback()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
+119
-15
@@ -2,7 +2,7 @@
|
||||
from datetime import datetime, timezone
|
||||
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 pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
@@ -14,16 +14,32 @@ from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import Event, Host
|
||||
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
||||
from app.utils.sql_like import escape_ilike_pattern
|
||||
from app.services.agent_commands import (
|
||||
command_response_fields,
|
||||
command_to_dict,
|
||||
get_command_by_uuid,
|
||||
)
|
||||
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_for_host
|
||||
from app.services.ssh_connect import (
|
||||
HostNotLinuxError as SshHostNotLinuxError,
|
||||
HostTargetMissingError as SshHostTargetMissingError,
|
||||
)
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
|
||||
from app.services.ingest import ingest_event
|
||||
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.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.notify_dispatch import (
|
||||
AUTH_LOGIN_SUCCESS_TYPES,
|
||||
@@ -36,6 +52,9 @@ from app.services.notify_dispatch import (
|
||||
notify_lifecycle,
|
||||
notify_problem,
|
||||
notify_rdg_connection,
|
||||
schedule_notify_auth_login,
|
||||
schedule_notify_daily_report,
|
||||
schedule_notify_lifecycle,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
@@ -64,6 +83,7 @@ def _ingest_response(event: Event, *, created: bool) -> IngestResponse:
|
||||
|
||||
@router.post("", response_model=IngestResponse)
|
||||
def post_event(
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
_api_key: str = Depends(get_api_key_auth),
|
||||
@@ -81,14 +101,18 @@ def post_event(
|
||||
event, created = ingest_event(db, payload)
|
||||
problem = None
|
||||
problem_created = False
|
||||
deferred_daily_report_id: int | None = None
|
||||
deferred_lifecycle_id: int | None = None
|
||||
deferred_auth_login_id: int | None = None
|
||||
if created:
|
||||
problem, problem_created = maybe_create_problem(db, event)
|
||||
maybe_auto_disconnect_stuck_rdp_session(db, event)
|
||||
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:
|
||||
notify_lifecycle(event, db=db)
|
||||
deferred_lifecycle_id = event.id
|
||||
elif event.type in AUTH_LOGIN_SUCCESS_TYPES:
|
||||
notify_auth_login(event, db=db)
|
||||
deferred_auth_login_id = event.id
|
||||
elif event.type in RDG_CONNECTION_TYPES:
|
||||
notify_rdg_connection(event, db=db)
|
||||
else:
|
||||
@@ -100,6 +124,13 @@ def post_event(
|
||||
logger.info("ingest duplicate event_id=%s", event.event_id)
|
||||
db.commit()
|
||||
|
||||
if deferred_daily_report_id is not None:
|
||||
background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id)
|
||||
if deferred_lifecycle_id is not None:
|
||||
background_tasks.add_task(schedule_notify_lifecycle, deferred_lifecycle_id)
|
||||
if deferred_auth_login_id is not None:
|
||||
background_tasks.add_task(schedule_notify_auth_login, deferred_auth_login_id)
|
||||
|
||||
body = _ingest_response(event, created=created)
|
||||
if problem is not None:
|
||||
body.problem_id = problem.id
|
||||
@@ -129,14 +160,22 @@ def list_events(
|
||||
type: str | None = None,
|
||||
host_id: int | None = None,
|
||||
hostname: str | None = None,
|
||||
include_hidden: bool = False,
|
||||
from_time: str | None = Query(None, alias="from"),
|
||||
to_time: str | None = Query(None, alias="to"),
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> 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)
|
||||
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))
|
||||
count_stmt = select(func.count()).select_from(Event).join(Host)
|
||||
if type_filter is not None:
|
||||
@@ -147,15 +186,17 @@ def list_events(
|
||||
stmt = stmt.where(Event.severity == severity)
|
||||
count_stmt = count_stmt.where(Event.severity == severity)
|
||||
if type:
|
||||
stmt = stmt.where(Event.type == type)
|
||||
count_stmt = count_stmt.where(Event.type == type)
|
||||
normalized = type.strip()
|
||||
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:
|
||||
stmt = stmt.where(Event.host_id == host_id)
|
||||
count_stmt = count_stmt.where(Event.host_id == host_id)
|
||||
if hostname:
|
||||
like = f"%{hostname}%"
|
||||
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||
stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
dt_from = _parse_optional_dt(from_time)
|
||||
dt_to = _parse_optional_dt(to_time, end_of_day=True)
|
||||
if dt_from:
|
||||
@@ -165,9 +206,9 @@ def list_events(
|
||||
stmt = stmt.where(Event.occurred_at <= dt_to)
|
||||
count_stmt = count_stmt.where(Event.occurred_at <= dt_to)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
stmt = stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
|
||||
count_stmt = count_stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
|
||||
like = f"%{escape_ilike_pattern(q)}%"
|
||||
stmt = stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
|
||||
count_stmt = count_stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
|
||||
|
||||
total = db.scalar(count_stmt) or 0
|
||||
rows = db.scalars(
|
||||
@@ -214,6 +255,72 @@ class LogoffActionBody(BaseModel):
|
||||
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")
|
||||
|
||||
if event.host is None:
|
||||
raise HTTPException(status_code=400, detail="Event has no host")
|
||||
linux_cfg = get_effective_linux_admin_for_host(db, event.host)
|
||||
win_cfg = get_effective_win_admin_for_host(db, event.host)
|
||||
sid = (body.session_id if body else None) or event_session_id(event)
|
||||
|
||||
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)
|
||||
def post_event_qwinsta(
|
||||
event_db_id: int,
|
||||
@@ -267,14 +374,11 @@ def get_event(
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> EventDetail:
|
||||
hidden = get_hidden_event_types(db)
|
||||
event = db.scalar(
|
||||
select(Event).where(Event.id == event_db_id).options(joinedload(Event.host))
|
||||
)
|
||||
if event is None:
|
||||
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)
|
||||
return EventDetail(
|
||||
**base.model_dump(),
|
||||
|
||||
+337
-13
@@ -1,5 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -10,6 +12,7 @@ from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import Event, Host
|
||||
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
||||
from app.utils.sql_like import escape_ilike_pattern
|
||||
from app.services.agent_version import (
|
||||
latest_agent_versions_by_product,
|
||||
reference_agent_versions_by_product,
|
||||
@@ -22,16 +25,33 @@ from app.services.agent_update import (
|
||||
)
|
||||
from app.services.host_remote_actions import (
|
||||
RemoteActionAlreadyRunningError,
|
||||
cancel_host_remote_action,
|
||||
clear_stale_running_remote_actions,
|
||||
get_remote_action_status,
|
||||
run_agent_fallback_action,
|
||||
run_ssh_monitor_update_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.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.linux_admin_settings import get_effective_linux_admin_config
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
from app.services.host_mgmt_credentials import (
|
||||
get_host_mgmt_access_view,
|
||||
upsert_host_mgmt_credentials,
|
||||
)
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError,
|
||||
@@ -86,8 +106,8 @@ def list_hosts(
|
||||
stmt = stmt.where(Host.product == product)
|
||||
count_stmt = count_stmt.where(Host.product == product)
|
||||
if hostname:
|
||||
like = f"%{hostname}%"
|
||||
host_match = Host.hostname.ilike(like) | Host.display_name.ilike(like)
|
||||
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||
host_match = Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")
|
||||
stmt = stmt.where(host_match)
|
||||
count_stmt = count_stmt.where(host_match)
|
||||
|
||||
@@ -158,9 +178,16 @@ def list_hosts(
|
||||
page_size=page_size,
|
||||
latest_agent_versions=latest_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(
|
||||
host: Host,
|
||||
*,
|
||||
@@ -276,6 +303,23 @@ class HostAgentConfigResponse(BaseModel):
|
||||
settings: dict[str, object]
|
||||
|
||||
|
||||
class HostMgmtAccessResponse(BaseModel):
|
||||
host_id: int
|
||||
has_override: bool
|
||||
user: str | None = None
|
||||
password_set: bool = False
|
||||
password_hint: str | None = None
|
||||
effective_source: str
|
||||
effective_configured: bool
|
||||
effective_user: str | None = None
|
||||
|
||||
|
||||
class HostMgmtAccessUpdate(BaseModel):
|
||||
user: str | None = None
|
||||
password: str | None = None
|
||||
clear: bool = False
|
||||
|
||||
|
||||
class HostRemoteActionStartResponse(BaseModel):
|
||||
status: str
|
||||
host_id: int
|
||||
@@ -283,6 +327,46 @@ class HostRemoteActionStartResponse(BaseModel):
|
||||
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):
|
||||
active: bool
|
||||
host_id: int
|
||||
@@ -302,6 +386,48 @@ class HostRemoteActionJobResponse(BaseModel):
|
||||
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(
|
||||
result: SshCommandResult,
|
||||
*,
|
||||
@@ -364,9 +490,12 @@ def test_host_winrm(
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
cfg = get_effective_win_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
|
||||
try:
|
||||
targets = iter_winrm_targets(host)
|
||||
@@ -427,9 +556,12 @@ def test_host_ssh(
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
cfg = get_effective_linux_admin_config(db)
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
|
||||
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
|
||||
_set_ssh_admin_status(host, response.ok)
|
||||
@@ -451,9 +583,12 @@ def update_host_agent_via_ssh(
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
cfg = get_effective_linux_admin_config(db)
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
|
||||
title = "Обновление ssh-monitor (SSH)"
|
||||
try:
|
||||
@@ -462,6 +597,7 @@ def update_host_agent_via_ssh(
|
||||
host,
|
||||
title=title,
|
||||
runner=run_ssh_monitor_update_action,
|
||||
poll_remote_log=True,
|
||||
)
|
||||
except RemoteActionAlreadyRunningError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
@@ -547,9 +683,146 @@ def get_host_remote_job(
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
db.refresh(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_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
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_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
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_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
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_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
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)
|
||||
def patch_host_agent_config(
|
||||
host_id: int,
|
||||
@@ -572,6 +845,57 @@ def patch_host_agent_config(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{host_id}/access", response_model=HostMgmtAccessResponse)
|
||||
def get_host_access(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostMgmtAccessResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
view = get_host_mgmt_access_view(db, host)
|
||||
return HostMgmtAccessResponse(
|
||||
host_id=view.host_id,
|
||||
has_override=view.has_override,
|
||||
user=view.user,
|
||||
password_set=view.password_set,
|
||||
password_hint=view.password_hint,
|
||||
effective_source=view.effective_source,
|
||||
effective_configured=view.effective_configured,
|
||||
effective_user=view.effective_user,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{host_id}/access", response_model=HostMgmtAccessResponse)
|
||||
def put_host_access(
|
||||
host_id: int,
|
||||
body: HostMgmtAccessUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostMgmtAccessResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
view = upsert_host_mgmt_credentials(
|
||||
db,
|
||||
host,
|
||||
user=body.user,
|
||||
password=body.password,
|
||||
clear=body.clear,
|
||||
)
|
||||
return HostMgmtAccessResponse(
|
||||
host_id=view.host_id,
|
||||
has_override=view.has_override,
|
||||
user=view.user,
|
||||
password_set=view.password_set,
|
||||
password_hint=view.password_hint,
|
||||
effective_source=view.effective_source,
|
||||
effective_configured=view.effective_configured,
|
||||
effective_user=view.effective_user,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||
def get_host_agent_config(
|
||||
host_id: int,
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.database import get_db
|
||||
|
||||
from app.models import Event, Host, Problem, ProblemEvent
|
||||
from app.services.event_type_visibility import get_hidden_event_types
|
||||
from app.utils.sql_like import escape_ilike_pattern
|
||||
|
||||
|
||||
|
||||
@@ -193,11 +194,11 @@ def list_problems(
|
||||
|
||||
if hostname:
|
||||
|
||||
like = f"%{hostname}%"
|
||||
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||
|
||||
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
|
||||
if created_within_hours is not None:
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ from app.services.win_admin_settings import (
|
||||
get_effective_win_admin_config,
|
||||
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_update_settings import (
|
||||
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):
|
||||
configured: bool
|
||||
user: str | None = None
|
||||
@@ -703,3 +746,115 @@ def test_agent_git_release(
|
||||
git_errors=dict(git_release.errors),
|
||||
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)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_auth import verify_access_token
|
||||
from app.auth.stream_cookie import STREAM_COOKIE_NAME
|
||||
from app.database import SessionLocal, get_db
|
||||
from app.models import Event, Problem
|
||||
from app.config import get_settings
|
||||
@@ -67,9 +68,12 @@ async def _sse_generator():
|
||||
|
||||
|
||||
def _sse_auth(
|
||||
token: str = Query(..., description="JWT access_token"),
|
||||
sac_stream: str | None = Cookie(None, alias=STREAM_COOKIE_NAME),
|
||||
db: Session = Depends(get_db),
|
||||
) -> str:
|
||||
token = (sac_stream or "").strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="SSE authentication required")
|
||||
return verify_access_token(token, db=db)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
@@ -6,16 +7,28 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import ApiKey
|
||||
|
||||
security_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_api_key(raw_key: str) -> str:
|
||||
def _legacy_hash_api_key(raw_key: str) -> str:
|
||||
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def hash_api_key(raw_key: str) -> str:
|
||||
secret = get_settings().jwt_secret.encode("utf-8")
|
||||
return hmac.new(secret, raw_key.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def verify_api_key_hash(raw_key: str, stored_hash: str) -> bool:
|
||||
if hmac.compare_digest(hash_api_key(raw_key), stored_hash):
|
||||
return True
|
||||
return hmac.compare_digest(_legacy_hash_api_key(raw_key), stored_hash)
|
||||
|
||||
|
||||
def generate_api_key() -> tuple[str, str, str]:
|
||||
"""Returns (full_key, prefix, hash)."""
|
||||
raw = f"sac_{secrets.token_urlsafe(32)}"
|
||||
@@ -24,9 +37,11 @@ def generate_api_key() -> tuple[str, str, str]:
|
||||
|
||||
|
||||
def verify_api_key(db: Session, raw_key: str) -> bool:
|
||||
key_hash = hash_api_key(raw_key)
|
||||
row = db.scalar(select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active.is_(True)))
|
||||
return row is not None
|
||||
rows = db.scalars(select(ApiKey).where(ApiKey.is_active.is_(True))).all()
|
||||
for row in rows:
|
||||
if verify_api_key_hash(raw_key, row.key_hash):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_api_key_auth(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""httpOnly cookie auth for SSE (EventSource cannot send Authorization header)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Response
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
STREAM_COOKIE_NAME = "sac_stream"
|
||||
STREAM_COOKIE_PATH = "/api/v1/stream"
|
||||
|
||||
|
||||
def stream_cookie_kwargs(settings: Settings) -> dict[str, object]:
|
||||
secure = settings.sac_public_url.lower().startswith("https://")
|
||||
return {
|
||||
"key": STREAM_COOKIE_NAME,
|
||||
"httponly": True,
|
||||
"secure": secure,
|
||||
"samesite": "strict",
|
||||
"path": STREAM_COOKIE_PATH,
|
||||
"max_age": max(60, int(settings.jwt_expire_minutes) * 60),
|
||||
}
|
||||
|
||||
|
||||
def set_stream_auth_cookie(response: Response, token: str, settings: Settings) -> None:
|
||||
response.set_cookie(value=token, **stream_cookie_kwargs(settings))
|
||||
|
||||
|
||||
def clear_stream_auth_cookie(response: Response, settings: Settings) -> None:
|
||||
kwargs = stream_cookie_kwargs(settings)
|
||||
response.delete_cookie(
|
||||
key=kwargs["key"],
|
||||
path=kwargs["path"],
|
||||
secure=bool(kwargs["secure"]),
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
+20
-2
@@ -34,10 +34,18 @@ class Settings(BaseSettings):
|
||||
model_config = _settings_config()
|
||||
|
||||
database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac"
|
||||
sac_db_pool_size: int = 15
|
||||
sac_db_max_overflow: int = 25
|
||||
sac_uvicorn_workers: int = 4
|
||||
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_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = 60 * 24
|
||||
# Fail-fast on weak JWT/CORS defaults (disable only for local dev/tests).
|
||||
sac_security_enforce: bool = True
|
||||
sac_ingest_max_body_bytes: int = 2_097_152
|
||||
|
||||
sac_bootstrap_api_key: str = ""
|
||||
sac_admin_username: str = "admin"
|
||||
@@ -106,14 +114,20 @@ class Settings(BaseSettings):
|
||||
sac_rdg_flap_window_min_sec: int = 1
|
||||
sac_rdg_flap_window_max_sec: int = 10
|
||||
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)
|
||||
sac_win_admin_user: str = ""
|
||||
sac_win_admin_password: str = ""
|
||||
sac_winrm_use_https: bool = False
|
||||
sac_winrm_server_cert_validation: str = "validate"
|
||||
|
||||
# Linux SSH admin for remote agent update (fallback SSH, phase 5)
|
||||
sac_linux_admin_user: str = ""
|
||||
sac_linux_admin_password: str = ""
|
||||
sac_ssh_known_hosts_file: str = "/opt/security-alert-center/config/ssh_known_hosts"
|
||||
sac_ssh_auto_add_host_key: bool = False
|
||||
|
||||
# Agent updates (mode gpo|sac, recommended versions, WinRM fallback script)
|
||||
sac_agent_update_mode: str = "gpo"
|
||||
@@ -124,8 +138,8 @@ class Settings(BaseSettings):
|
||||
sac_agent_min_rdp_version: str = ""
|
||||
sac_agent_min_ssh_version: str = ""
|
||||
sac_win_agent_update_script: str = ""
|
||||
sac_agent_rdp_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
|
||||
sac_agent_ssh_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
|
||||
sac_agent_rdp_git_repo_url: str = "https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
|
||||
sac_agent_ssh_git_repo_url: str = "https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
|
||||
sac_agent_git_branch: str = "main"
|
||||
sac_agent_git_cache_dir: str = "/opt/security-alert-center/cache/agent-repos"
|
||||
sac_agent_git_cache_ttl_minutes: int = 30
|
||||
@@ -139,6 +153,10 @@ class Settings(BaseSettings):
|
||||
sac_login_max_failures: int = 3
|
||||
sac_login_failure_window_minutes: int = 15
|
||||
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)
|
||||
sac_fcm_enabled: bool = False
|
||||
|
||||
@@ -23,6 +23,7 @@ DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
|
||||
# RDP / Windows
|
||||
"rdp.login.success": "info",
|
||||
"rdp.login.failed": "warning",
|
||||
"rdp.session.logoff": "info",
|
||||
"rdp.shadow.control.started": "warning",
|
||||
"rdp.shadow.control.stopped": "info",
|
||||
"rdp.shadow.control.permission": "warning",
|
||||
|
||||
@@ -6,7 +6,11 @@ from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
from app.config import 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)
|
||||
|
||||
|
||||
|
||||
+26
-4
@@ -1,4 +1,4 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
@@ -13,7 +13,9 @@ from app.api.v1.router import api_router
|
||||
from app.auth.api_key import hash_api_key
|
||||
from app.config import get_settings
|
||||
from app.database import SessionLocal
|
||||
from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware
|
||||
from app.models import ApiKey
|
||||
from app.security_bootstrap import validate_security_settings, warn_relaxed_security
|
||||
from app.services.user_auth import bootstrap_admin_user
|
||||
from app.version import APP_VERSION, APP_VERSION_LABEL
|
||||
|
||||
@@ -53,15 +55,30 @@ def bootstrap_users() -> None:
|
||||
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
|
||||
async def lifespan(_app: FastAPI):
|
||||
settings = get_settings()
|
||||
validate_security_settings(settings)
|
||||
warn_relaxed_security(settings)
|
||||
logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION)
|
||||
bootstrap_api_key()
|
||||
bootstrap_users()
|
||||
bootstrap_stale_remote_actions()
|
||||
|
||||
stop_scan = asyncio.Event()
|
||||
scan_task: asyncio.Task | None = None
|
||||
settings = get_settings()
|
||||
if settings.sac_host_silence_scan_enabled:
|
||||
from app.jobs.host_silence_background import host_silence_scan_loop
|
||||
|
||||
@@ -95,13 +112,18 @@ def create_app() -> FastAPI:
|
||||
lifespan=lifespan,
|
||||
)
|
||||
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||
wildcard = origins == ["*"]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins if origins != ["*"] else ["*"],
|
||||
allow_credentials=True,
|
||||
allow_origins=origins if not wildcard else ["*"],
|
||||
allow_credentials=not wildcard,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(
|
||||
IngestBodySizeLimitMiddleware,
|
||||
max_bytes=settings.sac_ingest_max_body_bytes,
|
||||
)
|
||||
from app.api.v1.health import router as health_router
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Limit POST /api/v1/events body size (matches nginx client_max_body_size)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
class IngestBodySizeLimitMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, *, max_bytes: int = 2_097_152) -> None:
|
||||
super().__init__(app)
|
||||
self._max_bytes = max_bytes
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.method == "POST" and request.url.path.rstrip("/") == "/api/v1/events":
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length:
|
||||
try:
|
||||
size = int(content_length)
|
||||
except ValueError:
|
||||
size = 0
|
||||
if size > self._max_bytes:
|
||||
return JSONResponse(
|
||||
status_code=413,
|
||||
content={"detail": f"Request body too large (max {self._max_bytes} bytes)"},
|
||||
)
|
||||
return await call_next(request)
|
||||
@@ -26,6 +26,9 @@ class Host(Base):
|
||||
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
||||
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||
ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# Optional override for SSH/WinRM (home PCs, unique local admin). Empty → global Settings.
|
||||
mgmt_user: Mapped[str | None] = mapped_column(String(256))
|
||||
mgmt_password: Mapped[str | None] = mapped_column(Text)
|
||||
pending_agent_update: Mapped[bool] = mapped_column(default=False, server_default="false")
|
||||
pending_update_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
pending_update_target_version: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
@@ -33,3 +33,8 @@ class UiSettings(Base):
|
||||
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_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,
|
||||
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):
|
||||
@@ -70,9 +78,13 @@ class EventSummary(BaseModel):
|
||||
title: str
|
||||
summary: str
|
||||
actor_user: str | None = None
|
||||
session_duration_sec: int | None = None
|
||||
rdg_flap: bool = False
|
||||
rdg_flap_pair_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}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Fail-fast checks for insecure production defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger("sac")
|
||||
|
||||
_WEAK_JWT_SECRETS = frozenset(
|
||||
{
|
||||
"",
|
||||
"change-me-in-production",
|
||||
"change-me-openssl-rand-hex-32",
|
||||
"CHANGE_ME_openssl_rand_hex_32",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_local_dev_url(public_url: str) -> bool:
|
||||
parsed = urlparse(public_url.strip() or "http://localhost:8000")
|
||||
host = (parsed.hostname or "").lower()
|
||||
return host in {"localhost", "127.0.0.1", "::1", "testserver"}
|
||||
|
||||
|
||||
def validate_security_settings(settings: Settings) -> None:
|
||||
"""Raise on dangerous defaults when SAC_SECURITY_ENFORCE=true."""
|
||||
if not settings.sac_security_enforce:
|
||||
return
|
||||
|
||||
if settings.jwt_secret in _WEAK_JWT_SECRETS:
|
||||
raise RuntimeError(
|
||||
"JWT_SECRET is missing or uses an insecure default. "
|
||||
"Set a random value in sac-api.env (openssl rand -hex 32)."
|
||||
)
|
||||
|
||||
if settings.cors_origins.strip() == "*" and not _is_local_dev_url(settings.sac_public_url):
|
||||
raise RuntimeError(
|
||||
"CORS_ORIGINS=* is not allowed with SAC_SECURITY_ENFORCE=true on non-local SAC_PUBLIC_URL. "
|
||||
"Set CORS_ORIGINS to your UI origin, e.g. https://sac.example.com"
|
||||
)
|
||||
|
||||
|
||||
def warn_relaxed_security(settings: Settings) -> None:
|
||||
if settings.sac_security_enforce:
|
||||
return
|
||||
if settings.jwt_secret in _WEAK_JWT_SECRETS:
|
||||
logger.warning("JWT_SECRET uses insecure default — set a strong secret before production")
|
||||
if settings.cors_origins.strip() == "*":
|
||||
logger.warning("CORS_ORIGINS=* — restrict to explicit origins in production")
|
||||
@@ -7,17 +7,22 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import AgentCommand, Host
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
from app.services.win_admin_settings import (
|
||||
get_effective_win_admin_config,
|
||||
get_effective_win_admin_for_host,
|
||||
)
|
||||
|
||||
|
||||
def _win_admin_configured() -> bool:
|
||||
return get_effective_win_admin_config().configured
|
||||
|
||||
|
||||
def win_admin_run_as() -> dict[str, str] | None:
|
||||
cfg = get_effective_win_admin_config()
|
||||
def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dict[str, str] | None:
|
||||
if host is not None and db is not None:
|
||||
cfg = get_effective_win_admin_for_host(db, host)
|
||||
else:
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
if not cfg.configured:
|
||||
return None
|
||||
return {
|
||||
@@ -26,7 +31,13 @@ def win_admin_run_as() -> dict[str, str] | None:
|
||||
}
|
||||
|
||||
|
||||
def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict:
|
||||
def command_to_dict(
|
||||
cmd: AgentCommand,
|
||||
*,
|
||||
include_run_as: bool = False,
|
||||
db: Session | None = None,
|
||||
host: Host | None = None,
|
||||
) -> dict:
|
||||
out = {
|
||||
"id": cmd.command_uuid,
|
||||
"type": cmd.command_type,
|
||||
@@ -38,7 +49,7 @@ def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict:
|
||||
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
|
||||
}
|
||||
if include_run_as and cmd.status == "pending":
|
||||
run_as = win_admin_run_as()
|
||||
run_as = win_admin_run_as(db, host)
|
||||
if run_as:
|
||||
out["run_as"] = run_as
|
||||
return out
|
||||
|
||||
@@ -27,5 +27,5 @@ def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]:
|
||||
"config_revision": int(host.agent_config_revision or 0),
|
||||
"config": config_payload,
|
||||
"update": update_block,
|
||||
"commands": [command_to_dict(c, include_run_as=True) for c in pending],
|
||||
"commands": [command_to_dict(c, include_run_as=True, db=db, host=host) for c in pending],
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.services.agent_version import (
|
||||
host_version_outdated,
|
||||
latest_agent_versions_by_product,
|
||||
)
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||
from app.services.ssh_connect import (
|
||||
HostNotLinuxError,
|
||||
HostTargetMissingError,
|
||||
@@ -28,7 +28,7 @@ from app.services.ssh_connect import (
|
||||
iter_ssh_targets,
|
||||
run_ssh_monitor_update,
|
||||
)
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError as WinRmHostTargetMissingError,
|
||||
@@ -249,7 +249,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbac
|
||||
product = (host.product or "").strip()
|
||||
|
||||
if product == PRODUCT_SSH or is_linux_host(host):
|
||||
linux_cfg = get_effective_linux_admin_config(db)
|
||||
linux_cfg = get_effective_linux_admin_for_host(db, host)
|
||||
agent_cfg = get_effective_agent_update_config(db)
|
||||
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
||||
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
||||
@@ -260,7 +260,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbac
|
||||
git_branch=branch,
|
||||
)
|
||||
elif product == PRODUCT_RDP or is_windows_host(host):
|
||||
win_cfg = get_effective_win_admin_config(db)
|
||||
win_cfg = get_effective_win_admin_for_host(db, host)
|
||||
result = run_windows_agent_update_fallback(
|
||||
host,
|
||||
win_cfg,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Resolve client IP behind reverse proxy (nginx $proxy_add_x_forwarded_for)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
def client_ip_from_request(request: Request) -> str:
|
||||
"""Client IP for rate limiting.
|
||||
|
||||
nginx with ``proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for``
|
||||
appends the real peer address as the *last* hop. Spoofed values in the
|
||||
incoming header therefore cannot replace the actual client IP.
|
||||
"""
|
||||
if request.client and request.client.host:
|
||||
direct = request.client.host.strip()
|
||||
else:
|
||||
direct = "unknown"
|
||||
|
||||
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
|
||||
if not forwarded:
|
||||
return direct[:64]
|
||||
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if not parts:
|
||||
return direct[:64]
|
||||
return parts[-1][:64]
|
||||
@@ -16,6 +16,10 @@ SSH_BAN_TYPES = frozenset({"ssh.ip.banned"})
|
||||
|
||||
_SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\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)
|
||||
_NOTIFICATION_SOURCE_RE = re.compile(r"(?m)^📡\s*Оповещение:\s*")
|
||||
_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()
|
||||
|
||||
|
||||
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]:
|
||||
"""Разбивает «👤 u1 👤 u2» на отдельные строки (как в отчёте агента)."""
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
if not entry or is_active_users_empty_line(entry):
|
||||
return []
|
||||
if entry.count("👤") <= 1:
|
||||
line = entry if entry.startswith("👤") else f"👤 {entry}"
|
||||
@@ -231,21 +246,71 @@ def normalize_active_users_in_body(body: str) -> str:
|
||||
break
|
||||
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
|
||||
break
|
||||
if stripped.startswith("(нет данных"):
|
||||
if is_active_users_empty_line(stripped):
|
||||
out[-1] = _fix_active_users_header_count(out[-1], 0)
|
||||
out.append(cur)
|
||||
i += 1
|
||||
break
|
||||
user_lines.extend(split_active_user_tokens(cur))
|
||||
i += 1
|
||||
user_lines = [ln for ln in user_lines if not is_active_users_empty_line(ln.strip())]
|
||||
if user_lines:
|
||||
out[-1] = _fix_active_users_header_count(out[-1], len(user_lines))
|
||||
out.extend(user_lines)
|
||||
else:
|
||||
out[-1] = _fix_active_users_header_count(out[-1], 0)
|
||||
continue
|
||||
out.append(line)
|
||||
i += 1
|
||||
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:
|
||||
"""Приводит текст отчёта (агент/SAC) к единому компактному виду."""
|
||||
text = collapse_blank_lines(body.replace("\r\n", "\n"))
|
||||
@@ -422,5 +487,6 @@ def normalize_daily_report_details(
|
||||
stats = out.get("stats")
|
||||
if isinstance(stats, dict):
|
||||
stats = enrich_stats_for_storage(platform, dict(stats))
|
||||
stats = reconcile_stats_from_report_body(stats, normalized_body, platform)
|
||||
out["stats"] = stats
|
||||
return out
|
||||
|
||||
@@ -15,6 +15,7 @@ ACTOR_USER_EVENT_TYPES: frozenset[str] = frozenset(
|
||||
"session.logind.failed",
|
||||
"rdp.login.success",
|
||||
"rdp.login.failed",
|
||||
"rdp.session.logoff",
|
||||
"rdp.shadow.control.started",
|
||||
"rdp.shadow.control.stopped",
|
||||
"rdp.shadow.control.permission",
|
||||
|
||||
@@ -3,7 +3,10 @@ from sqlalchemy.orm import Session
|
||||
from app.models.event import Event
|
||||
from app.schemas.list_models import EventSummary
|
||||
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.session_duration import extract_session_duration_sec
|
||||
|
||||
|
||||
def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
|
||||
@@ -16,6 +19,17 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
|
||||
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(
|
||||
id=event.id,
|
||||
event_id=event.event_id,
|
||||
@@ -28,10 +42,16 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
|
||||
category=event.category,
|
||||
type=event.type,
|
||||
severity=event.severity,
|
||||
title=event.title,
|
||||
summary=event.summary,
|
||||
title=title,
|
||||
summary=summary,
|
||||
actor_user=extract_event_actor_user(event.type, event.details),
|
||||
session_duration_sec=extract_session_duration_sec(
|
||||
event.details if isinstance(event.details, dict) else None
|
||||
),
|
||||
rdg_flap=rdg_flap,
|
||||
rdg_flap_pair_event_id=rdg_flap_pair_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
|
||||
|
||||
|
||||
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:
|
||||
if not hidden:
|
||||
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")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Per-host management credentials (override global Win/Linux admin)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.host import Host
|
||||
from app.services.linux_admin_settings import (
|
||||
LinuxAdminConfig,
|
||||
get_effective_linux_admin_for_host,
|
||||
)
|
||||
from app.services.win_admin_settings import (
|
||||
WinAdminConfig,
|
||||
get_effective_win_admin_for_host,
|
||||
normalize_win_admin_user,
|
||||
)
|
||||
|
||||
|
||||
def mask_secret(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= 4:
|
||||
return "****"
|
||||
return f"{text[:2]}…{text[-2:]}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostMgmtAccessView:
|
||||
host_id: int
|
||||
has_override: bool
|
||||
user: str | None
|
||||
password_set: bool
|
||||
password_hint: str | None
|
||||
effective_source: str
|
||||
effective_configured: bool
|
||||
effective_user: str | None
|
||||
|
||||
|
||||
def _effective_for_host(db: Session, host: Host) -> WinAdminConfig | LinuxAdminConfig:
|
||||
os_family = (host.os_family or "").strip().lower()
|
||||
if os_family == "windows" or (host.product or "").strip().lower() in {"rdp-login-monitor", "rdp"}:
|
||||
return get_effective_win_admin_for_host(db, host)
|
||||
if os_family == "linux" or (host.product or "").strip().lower() in {"ssh-monitor", "ssh"}:
|
||||
return get_effective_linux_admin_for_host(db, host)
|
||||
# Fallback: try host override first via win helper (same columns), then global win, then linux.
|
||||
win_cfg = get_effective_win_admin_for_host(db, host)
|
||||
if win_cfg.configured:
|
||||
return win_cfg
|
||||
return get_effective_linux_admin_for_host(db, host)
|
||||
|
||||
|
||||
def get_host_mgmt_access_view(db: Session, host: Host) -> HostMgmtAccessView:
|
||||
host_user = (host.mgmt_user or "").strip() or None
|
||||
host_password = (host.mgmt_password or "").strip() or None
|
||||
has_override = bool(host_user or host_password)
|
||||
effective = _effective_for_host(db, host)
|
||||
return HostMgmtAccessView(
|
||||
host_id=host.id,
|
||||
has_override=has_override,
|
||||
user=host_user,
|
||||
password_set=bool(host_password),
|
||||
password_hint=mask_secret(host_password),
|
||||
effective_source=effective.source,
|
||||
effective_configured=effective.configured,
|
||||
effective_user=effective.user or None,
|
||||
)
|
||||
|
||||
|
||||
def upsert_host_mgmt_credentials(
|
||||
db: Session,
|
||||
host: Host,
|
||||
*,
|
||||
user: str | None = None,
|
||||
password: str | None = None,
|
||||
clear: bool = False,
|
||||
) -> HostMgmtAccessView:
|
||||
"""Update per-host credentials.
|
||||
|
||||
- clear=True → wipe host override (use global Settings).
|
||||
- user="" → clear username.
|
||||
- password omitted (None) → keep existing password.
|
||||
- password="" → clear password.
|
||||
"""
|
||||
if clear:
|
||||
host.mgmt_user = None
|
||||
host.mgmt_password = None
|
||||
db.commit()
|
||||
db.refresh(host)
|
||||
return get_host_mgmt_access_view(db, host)
|
||||
|
||||
if user is not None:
|
||||
cleaned = user.strip()
|
||||
if not cleaned:
|
||||
host.mgmt_user = None
|
||||
else:
|
||||
# Preserve DOMAIN\\user form for Windows; for Linux leave as-is after strip.
|
||||
os_family = (host.os_family or "").strip().lower()
|
||||
if os_family == "windows" or "\\" in cleaned or "@" in cleaned:
|
||||
host.mgmt_user = normalize_win_admin_user(cleaned)
|
||||
else:
|
||||
host.mgmt_user = cleaned
|
||||
|
||||
if password is not None:
|
||||
if not password.strip():
|
||||
host.mgmt_password = None
|
||||
else:
|
||||
host.mgmt_password = password
|
||||
|
||||
db.commit()
|
||||
db.refresh(host)
|
||||
return get_host_mgmt_access_view(db, host)
|
||||
@@ -14,9 +14,14 @@ from app.database import SessionLocal
|
||||
from app.models import Host
|
||||
from app.services.agent_update import execute_agent_update_fallback
|
||||
from app.services.agent_update_types import AgentUpdateFallbackResult
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||
from app.services.agent_update_settings import get_effective_agent_update_config
|
||||
from app.services.ssh_connect import iter_ssh_targets, run_ssh_monitor_update, SshCommandResult
|
||||
from app.services.ssh_connect import (
|
||||
iter_ssh_targets,
|
||||
run_ssh_monitor_update,
|
||||
SshCommandResult,
|
||||
tail_ssh_monitor_update_log,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,6 +81,61 @@ def _result_payload(
|
||||
}
|
||||
|
||||
|
||||
def _completion_title(base_title: str, result: SshCommandResult) -> str:
|
||||
if result.ok:
|
||||
return f"{base_title} — готово"
|
||||
return f"{base_title} — ошибка"
|
||||
|
||||
|
||||
def _completion_message(result: SshCommandResult) -> str:
|
||||
if result.ok:
|
||||
version = (result.agent_version or "").strip()
|
||||
if version:
|
||||
return f"Обновление завершено успешно. Версия агента: {version}"
|
||||
return "Обновление завершено успешно"
|
||||
return (result.message or "Обновление не удалось").strip()
|
||||
|
||||
|
||||
def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str:
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
return ""
|
||||
try:
|
||||
targets = iter_ssh_targets(host)
|
||||
except Exception:
|
||||
return ""
|
||||
for target in targets:
|
||||
try:
|
||||
tail = tail_ssh_monitor_update_log(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
lines=lines,
|
||||
)
|
||||
if tail:
|
||||
return tail
|
||||
except Exception:
|
||||
logger.debug("final update log tail failed host_id=%s target=%s", host.id, target, exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def _enrich_ssh_update_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
base_title: str,
|
||||
result: SshCommandResult,
|
||||
previous_output: str,
|
||||
log_tail: str,
|
||||
) -> dict[str, Any]:
|
||||
payload["title"] = _completion_title(base_title, result)
|
||||
payload["message"] = _completion_message(result)
|
||||
if log_tail:
|
||||
payload["output"] = log_tail
|
||||
elif previous_output.strip():
|
||||
payload["output"] = previous_output
|
||||
return payload
|
||||
|
||||
|
||||
def _mark_running(db: Session, host: Host, *, title: str) -> str:
|
||||
started_at = _utcnow().isoformat()
|
||||
host.agent_update_state = "running"
|
||||
@@ -113,12 +173,70 @@ def _apply_ssh_update_result(db: Session, host: Host, result: SshCommandResult)
|
||||
host.agent_update_last_error = (result.message or "SSH update failed")[:2000]
|
||||
|
||||
|
||||
_REMOTE_LOG_POLL_SEC = 2.0
|
||||
|
||||
|
||||
def _poll_ssh_update_log_loop(
|
||||
host_id: int,
|
||||
*,
|
||||
stop: threading.Event,
|
||||
) -> None:
|
||||
"""Периодически подтягивает tail update_script.log в hosts.remote_action для UI."""
|
||||
while not stop.wait(_REMOTE_LOG_POLL_SEC):
|
||||
session = SessionLocal()
|
||||
try:
|
||||
row = session.get(Host, host_id)
|
||||
if row is None or (row.agent_update_state or "").strip().lower() != "running":
|
||||
continue
|
||||
payload = dict(row.remote_action or {})
|
||||
if (payload.get("status") or "").strip().lower() != "running":
|
||||
continue
|
||||
cfg = get_effective_linux_admin_for_host(session, row)
|
||||
if not cfg.configured:
|
||||
continue
|
||||
try:
|
||||
targets = iter_ssh_targets(row)
|
||||
except Exception:
|
||||
continue
|
||||
tail = ""
|
||||
for target in targets:
|
||||
try:
|
||||
tail = tail_ssh_monitor_update_log(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
lines=200,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("update log tail failed host_id=%s target=%s", host_id, target, exc_info=True)
|
||||
continue
|
||||
if tail:
|
||||
break
|
||||
session.refresh(row)
|
||||
if (row.agent_update_state or "").strip().lower() != "running":
|
||||
continue
|
||||
payload = dict(row.remote_action or {})
|
||||
if (payload.get("status") or "").strip().lower() != "running":
|
||||
continue
|
||||
if tail:
|
||||
payload["output"] = tail
|
||||
payload["message"] = "Выполняется обновление… (лог с хоста)"
|
||||
row.remote_action = payload
|
||||
session.commit()
|
||||
except Exception:
|
||||
logger.debug("remote log poll failed host_id=%s", host_id, exc_info=True)
|
||||
session.rollback()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def start_host_remote_action(
|
||||
db: Session,
|
||||
host: Host,
|
||||
*,
|
||||
title: str,
|
||||
runner: ActionRunner,
|
||||
poll_remote_log: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
host_id = int(host.id)
|
||||
with _lock:
|
||||
@@ -133,13 +251,35 @@ def start_host_remote_action(
|
||||
|
||||
def _worker() -> None:
|
||||
session = SessionLocal()
|
||||
stop_poll = threading.Event()
|
||||
poll_thread: threading.Thread | None = None
|
||||
if poll_remote_log and runner is run_ssh_monitor_update_action:
|
||||
poll_thread = threading.Thread(
|
||||
target=_poll_ssh_update_log_loop,
|
||||
args=(host_id,),
|
||||
kwargs={"stop": stop_poll},
|
||||
name=f"sac-remote-log-{host_id}",
|
||||
daemon=True,
|
||||
)
|
||||
poll_thread.start()
|
||||
try:
|
||||
row = session.get(Host, host_id)
|
||||
if row is None:
|
||||
return
|
||||
previous_output = (row.remote_action or {}).get("output") or ""
|
||||
result = runner(session, row)
|
||||
started = (row.remote_action or {}).get("started_at") or started_at
|
||||
row.remote_action = _result_payload(result, title=title, started_at=started)
|
||||
payload = _result_payload(result, title=title, started_at=started)
|
||||
if poll_remote_log and isinstance(result, SshCommandResult):
|
||||
log_tail = _fetch_ssh_update_log_tail(session, row)
|
||||
payload = _enrich_ssh_update_payload(
|
||||
payload,
|
||||
base_title=title,
|
||||
result=result,
|
||||
previous_output=previous_output,
|
||||
log_tail=log_tail,
|
||||
)
|
||||
row.remote_action = payload
|
||||
if isinstance(result, SshCommandResult):
|
||||
_apply_ssh_update_result(session, row, result)
|
||||
session.commit()
|
||||
@@ -167,6 +307,9 @@ def start_host_remote_action(
|
||||
}
|
||||
session.commit()
|
||||
finally:
|
||||
stop_poll.set()
|
||||
if poll_thread is not None:
|
||||
poll_thread.join(timeout=5.0)
|
||||
session.close()
|
||||
with _lock:
|
||||
_running.discard(host_id)
|
||||
@@ -179,7 +322,7 @@ def start_host_remote_action(
|
||||
|
||||
|
||||
def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult:
|
||||
cfg = get_effective_linux_admin_config(db)
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
@@ -219,12 +362,73 @@ def run_agent_fallback_action(db: Session, host: Host) -> AgentUpdateFallbackRes
|
||||
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]:
|
||||
payload = dict(host.remote_action or {})
|
||||
if not payload:
|
||||
return {"active": False, "host_id": host.id}
|
||||
status = payload.get("status") or host.agent_update_state or "unknown"
|
||||
active = status == "running" or host.agent_update_state == "running"
|
||||
agent_state = (host.agent_update_state or "").strip().lower()
|
||||
if agent_state == "running":
|
||||
active = True
|
||||
status = payload.get("status") or "running"
|
||||
elif agent_state in ("success", "failed"):
|
||||
active = False
|
||||
status = payload.get("status") or agent_state
|
||||
else:
|
||||
status = payload.get("status") or host.agent_update_state or "unknown"
|
||||
active = status == "running"
|
||||
return {
|
||||
"active": active,
|
||||
"host_id": host.id,
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
"""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.rdp_session_logoff import (
|
||||
event_closed_by_logoff,
|
||||
resolve_workstation_login_closed_by_logoff,
|
||||
)
|
||||
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 event_closed_by_logoff(event):
|
||||
return True
|
||||
if db is not None and event.type == "rdp.login.success":
|
||||
if resolve_workstation_login_closed_by_logoff(db, event):
|
||||
return True
|
||||
return resolve_workstation_login_closed(db, event)
|
||||
return 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 _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 windows_user_matches_session(login_user: str, session_user: str) -> bool:
|
||||
login = _normalize_sam_account(login_user)
|
||||
session = _normalize_sam_account(session_user)
|
||||
return bool(login and session and login == session)
|
||||
|
||||
|
||||
def filter_windows_sessions_for_user(
|
||||
sessions: list[HostSessionRow],
|
||||
login_user: str,
|
||||
) -> list[HostSessionRow]:
|
||||
user = (login_user or "").strip()
|
||||
if not user:
|
||||
return sessions
|
||||
return [s for s in sessions if windows_user_matches_session(user, s.user)]
|
||||
|
||||
|
||||
def _qwinsta_sam_account(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if "\\" in text:
|
||||
text = text.split("\\")[-1]
|
||||
if "@" in text:
|
||||
text = text.split("@")[0]
|
||||
return text.casefold()
|
||||
|
||||
|
||||
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]:
|
||||
"""Parse ``qwinsta`` output.
|
||||
|
||||
Disconnected sessions often have an empty SESSIONNAME column, so the line
|
||||
becomes ``USERNAME ID STATE`` (3 tokens). Older parsing required 4 tokens
|
||||
and skipped those rows — that broke RDG flap auto-logoff for Disc sessions.
|
||||
"""
|
||||
rows: list[HostSessionRow] = []
|
||||
filter_sam = _qwinsta_sam_account(filter_user or "")
|
||||
skip_users = frozenset({"services"})
|
||||
|
||||
for line in stdout.splitlines():
|
||||
text = line.strip()
|
||||
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
|
||||
continue
|
||||
parts = text.split()
|
||||
id_idx: int | None = None
|
||||
sid = 0
|
||||
for i, part in enumerate(parts):
|
||||
token = part.lstrip(">")
|
||||
if token.isdigit():
|
||||
id_idx = i
|
||||
sid = int(token)
|
||||
break
|
||||
if id_idx is None or id_idx < 1:
|
||||
continue
|
||||
state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else ""
|
||||
if not state or state.casefold().startswith("listen"):
|
||||
continue
|
||||
before = parts[:id_idx]
|
||||
if len(before) == 1:
|
||||
session_name = ""
|
||||
user_name = before[0].lstrip(">")
|
||||
else:
|
||||
session_name = before[0].lstrip(">")
|
||||
user_name = before[1]
|
||||
if user_name.casefold() in skip_users:
|
||||
continue
|
||||
if filter_sam and filter_sam not in _qwinsta_sam_account(user_name):
|
||||
continue
|
||||
rows.append(
|
||||
HostSessionRow(
|
||||
session_id=str(sid),
|
||||
user=user_name,
|
||||
session_name=session_name,
|
||||
state=state,
|
||||
)
|
||||
)
|
||||
|
||||
if rows or not filter_sam:
|
||||
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 = filter_windows_sessions_for_user(sessions, user) if user else sessions
|
||||
if len(matched) == 1:
|
||||
sid = matched[0].session_id
|
||||
elif not matched:
|
||||
# Пользователь уже вышел из RDP — qwinsta пуст, событие входа в SAC ещё «открыто».
|
||||
return WinRmCmdResult(
|
||||
ok=True,
|
||||
message="На хосте нет активной сессии пользователя (уже вышел из RDP)",
|
||||
target=qwinsta.target,
|
||||
stdout=qwinsta.stdout,
|
||||
)
|
||||
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 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 app.config import get_settings
|
||||
from app.models import Host, Problem
|
||||
from app.services.problem_rules import RuleMatch, build_fingerprint, host_silence_match_for_host
|
||||
from app.services.problems import _correlation_cutoff
|
||||
from app.services.problem_rules import RULE_HOST_SILENCE, RuleMatch, build_fingerprint, host_silence_match_for_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTIVE_HOST_SILENCE_STATUSES = ("open", "acknowledged")
|
||||
_HOST_SILENCE_SCAN_LOCK_KEY = 915_001_001
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostSilenceScanResult:
|
||||
@@ -25,45 +28,110 @@ class HostSilenceScanResult:
|
||||
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(
|
||||
db: Session,
|
||||
host_id: int,
|
||||
match: RuleMatch,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
hostname: str | None = None,
|
||||
) -> 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.
|
||||
"""
|
||||
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(
|
||||
host_id,
|
||||
match.correlation_type,
|
||||
match.rule_id,
|
||||
match.fingerprint_suffix,
|
||||
)
|
||||
cutoff = _correlation_cutoff(ref)
|
||||
|
||||
open_problem = db.scalar(
|
||||
select(Problem)
|
||||
.where(
|
||||
Problem.status == "open",
|
||||
Problem.fingerprint == fingerprint,
|
||||
Problem.host_id == host_id,
|
||||
Problem.last_seen_at >= cutoff,
|
||||
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,
|
||||
)
|
||||
.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()
|
||||
return open_problem, False
|
||||
return active_problem, False
|
||||
|
||||
settings = get_settings()
|
||||
cooldown_hours = settings.sac_host_silence_manual_resolve_cooldown_hours
|
||||
@@ -105,12 +173,27 @@ def open_or_refresh_host_silence_problem(
|
||||
.limit(1)
|
||||
)
|
||||
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.resolved_by = None
|
||||
manual_expired.summary = match.summary
|
||||
manual_expired.title = match.title
|
||||
manual_expired.last_seen_at = ref
|
||||
manual_expired.updated_at = ref
|
||||
_apply_host_silence_refresh(
|
||||
manual_expired,
|
||||
host_id=host_id,
|
||||
match=match,
|
||||
fingerprint=fingerprint,
|
||||
ref=ref,
|
||||
)
|
||||
db.flush()
|
||||
return manual_expired, True
|
||||
|
||||
@@ -125,13 +208,32 @@ def open_or_refresh_host_silence_problem(
|
||||
event_count=0,
|
||||
last_seen_at=ref,
|
||||
)
|
||||
db.add(problem)
|
||||
db.flush()
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.add(problem)
|
||||
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
|
||||
|
||||
|
||||
def run_host_silence_scan(db: Session, *, now: datetime | None = None) -> list[HostSilenceScanResult]:
|
||||
"""Найти 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)
|
||||
hosts = db.scalars(select(Host).order_by(Host.id)).all()
|
||||
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)
|
||||
if match is None:
|
||||
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:
|
||||
continue
|
||||
results.append(
|
||||
|
||||
@@ -9,6 +9,8 @@ from app.services.agent_update import process_agent_update_ingest
|
||||
from app.services.daily_report_format import normalize_daily_report_details
|
||||
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.rdp_session_logoff import close_workstation_session_for_rdp_logoff
|
||||
from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end
|
||||
|
||||
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
||||
|
||||
@@ -126,4 +128,15 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
|
||||
raise
|
||||
|
||||
process_agent_update_ingest(db, host, payload.get("type", ""), details)
|
||||
from app.services.rdg_workstation_session import (
|
||||
enrich_empty_login_from_rdg_success,
|
||||
enrich_workstation_login_user_from_rdg,
|
||||
)
|
||||
|
||||
if event.host is None:
|
||||
event.host = host
|
||||
enrich_workstation_login_user_from_rdg(db, event)
|
||||
enrich_empty_login_from_rdg_success(db, event)
|
||||
close_workstation_session_for_rdg_end(db, event)
|
||||
close_workstation_session_for_rdp_logoff(db, event)
|
||||
return event, True
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Effective Linux SSH admin credentials (DB overrides env)."""
|
||||
"""Effective Linux SSH admin credentials (host → DB → env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.host import Host
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
|
||||
@@ -14,7 +15,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
class LinuxAdminConfig:
|
||||
user: str
|
||||
password: str
|
||||
source: str # env | db
|
||||
source: str # host | env | db
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
@@ -80,3 +81,16 @@ def upsert_linux_admin_settings(
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_linux_admin_config(db)
|
||||
|
||||
|
||||
def get_effective_linux_admin_for_host(db: Session, host: Host) -> LinuxAdminConfig:
|
||||
"""Prefer per-host mgmt_* when both user and password are set; else global Settings/env."""
|
||||
host_user = (host.mgmt_user or "").strip()
|
||||
host_password = (host.mgmt_password or "").strip()
|
||||
if host_user and host_password:
|
||||
return LinuxAdminConfig(user=host_user, password=host_password, source="host")
|
||||
|
||||
global_cfg = get_effective_linux_admin_config(db)
|
||||
if host_user and global_cfg.password.strip():
|
||||
return LinuxAdminConfig(user=host_user, password=global_cfg.password, source="host")
|
||||
return global_cfg
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Rate limit failed SAC UI logins + optional Telegram alert."""
|
||||
"""Rate limit failed SAC UI logins + optional Telegram alert."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,6 +12,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.login_attempt import LoginAttempt
|
||||
from app.services.client_ip import client_ip_from_request
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,15 +38,6 @@ def get_login_rate_limit_config() -> LoginRateLimitConfig:
|
||||
)
|
||||
|
||||
|
||||
def client_ip_from_request(request: Request) -> str:
|
||||
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()[:64]
|
||||
if request.client and request.client.host:
|
||||
return request.client.host[:64]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _failure_count(db: Session, ip_address: str, *, since: datetime) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
@@ -91,6 +87,9 @@ def ensure_login_allowed(db: Session, request: Request) -> str:
|
||||
"""Raise 429 if IP exceeded failed login threshold. Returns client IP."""
|
||||
cfg = get_login_rate_limit_config()
|
||||
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)
|
||||
failures = _failure_count(db, ip_address, since=since)
|
||||
if failures >= cfg.max_failures:
|
||||
@@ -119,6 +118,9 @@ def record_login_failure(
|
||||
request: Request | None = None,
|
||||
) -> None:
|
||||
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()
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
|
||||
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
|
||||
@@ -4,16 +4,25 @@ import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Problem
|
||||
from app.models import Event, Host, Problem
|
||||
from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify
|
||||
from app.services.notification_cooldown import should_notify_event, should_notify_problem
|
||||
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.notification_severity import severity_meets_minimum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LIFECYCLE_EVENT_TYPE = "agent.lifecycle"
|
||||
PRIVILEGE_SUDO_TYPE = "privilege.sudo.command"
|
||||
SUDO_MAINTENANCE_MARKERS = (
|
||||
"update_ssh_monitor.sh",
|
||||
"update_via_sac",
|
||||
"update_script.log",
|
||||
"/opt/scripts/update",
|
||||
"agent-update-in-progress",
|
||||
)
|
||||
DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
||||
AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"})
|
||||
RDG_CONNECTION_TYPES = frozenset({
|
||||
@@ -29,6 +38,17 @@ def _event_telegram_via_agent(event: Event) -> bool:
|
||||
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:
|
||||
if policy.use_telegram:
|
||||
telegram_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||
@@ -55,6 +75,10 @@ def notify_event(event: Event, *, db: Session | None = None) -> None:
|
||||
# Heartbeat — только для UI/статуса хоста, не для Telegram/email/push.
|
||||
if event.type == HEARTBEAT_TYPE:
|
||||
return
|
||||
if _should_suppress_sudo_notify(event, db=db):
|
||||
return
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not severity_meets_minimum(event.severity, policy.min_severity):
|
||||
return
|
||||
@@ -89,14 +113,78 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
|
||||
|
||||
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
|
||||
"""
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
_dispatch_lifecycle_channels(event, db=db, policy=policy)
|
||||
|
||||
|
||||
def _host_sac_update_running(event: Event, db: Session | None) -> bool:
|
||||
"""Пока SAC выполняет agent-update на хосте — не слать шумные TG."""
|
||||
if db is None or not event.host_id:
|
||||
return False
|
||||
host = db.get(Host, event.host_id)
|
||||
if host is None:
|
||||
return False
|
||||
if (host.agent_update_state or "").strip().lower() == "running":
|
||||
logger.info(
|
||||
"notify skipped (host update running) type=%s host_id=%s event_id=%s",
|
||||
event.type,
|
||||
host.id,
|
||||
event.event_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sudo_event_is_agent_maintenance(event: Event) -> bool:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
cmd = str(details.get("command") or "").strip()
|
||||
if not cmd:
|
||||
cmd = str(event.summary or "").strip()
|
||||
text = cmd.casefold()
|
||||
return any(marker in text for marker in SUDO_MAINTENANCE_MARKERS)
|
||||
|
||||
|
||||
def _should_suppress_sudo_notify(event: Event, *, db: Session | None) -> bool:
|
||||
if event.type != PRIVILEGE_SUDO_TYPE:
|
||||
return False
|
||||
if _host_sac_update_running(event, db):
|
||||
return True
|
||||
if _sudo_event_is_agent_maintenance(event):
|
||||
logger.info(
|
||||
"notify sudo skipped maintenance command event_id=%s host_id=%s",
|
||||
event.event_id,
|
||||
event.host_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _lifecycle_suppress_notifications(event: Event, *, db: Session | None = None) -> bool:
|
||||
"""Не слать TG при штатном SAC/cron update (lifecycle с trigger deploy_recycle)."""
|
||||
if _host_sac_update_running(event, db):
|
||||
return True
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
trigger = str(details.get("trigger") or "").strip().lower()
|
||||
if trigger in ("deploy_recycle", "sac_update", "agent_update"):
|
||||
logger.info(
|
||||
"notify lifecycle skipped trigger=%s event_id=%s",
|
||||
trigger,
|
||||
event.event_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def notify_lifecycle(event: Event, *, db: Session | None = None) -> None:
|
||||
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
|
||||
if _lifecycle_suppress_notifications(event, db=db):
|
||||
return
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
@@ -108,6 +196,8 @@ def notify_auth_login(event: Event, *, db: Session | None = None) -> None:
|
||||
|
||||
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
|
||||
"""
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
@@ -116,7 +206,40 @@ def notify_auth_login(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)."""
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
_dispatch_lifecycle_channels(event, db=db, policy=policy)
|
||||
|
||||
|
||||
def schedule_notify_daily_report(event_db_id: int) -> None:
|
||||
"""Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST)."""
|
||||
_schedule_deferred_event_notify(event_db_id, notify_daily_report, label="daily report")
|
||||
|
||||
|
||||
def schedule_notify_lifecycle(event_db_id: int) -> None:
|
||||
"""Отложенное lifecycle-оповещение (после commit ingest)."""
|
||||
_schedule_deferred_event_notify(event_db_id, notify_lifecycle, label="lifecycle")
|
||||
|
||||
|
||||
def schedule_notify_auth_login(event_db_id: int) -> None:
|
||||
"""Отложенное оповещение об успешном RDP/SSH входе (после commit ingest)."""
|
||||
_schedule_deferred_event_notify(event_db_id, notify_auth_login, label="auth login")
|
||||
|
||||
|
||||
def _schedule_deferred_event_notify(event_db_id: int, handler, *, label: str) -> None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.get(Event, event_db_id)
|
||||
if event is None:
|
||||
logger.warning("deferred %s notify: event id=%s not found", label, event_db_id)
|
||||
return
|
||||
handler(event, db=db)
|
||||
except Exception:
|
||||
logger.exception("deferred %s notify failed event_db_id=%s", label, event_db_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -51,7 +51,7 @@ def open_or_append_problem(
|
||||
if ref.tzinfo is None:
|
||||
ref = ref.replace(tzinfo=timezone.utc)
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
if dt.tzinfo is None:
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
db: Session, event: Event
|
||||
) -> tuple[bool, int | None, int | None]:
|
||||
"""
|
||||
(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):
|
||||
pair_id = _stored_flap_pair_id(event)
|
||||
return True, pair_id, event.id
|
||||
if event.type == RDG_SUCCESS_TYPE:
|
||||
return True, pair_id, event.id
|
||||
return True, pair_id, pair_id
|
||||
|
||||
if event.type in RDG_END_TYPES:
|
||||
prior = find_rdg_success_before_end(db, event)
|
||||
if prior is not None:
|
||||
return True, prior.id, event.id
|
||||
return True, prior.id, prior.id
|
||||
|
||||
if event.type == RDG_SUCCESS_TYPE:
|
||||
end = find_rdg_end_after_success(db, event)
|
||||
if end is not None:
|
||||
return True, end.id, end.id
|
||||
return True, end.id, event.id
|
||||
|
||||
return False, None, None
|
||||
|
||||
@@ -10,8 +10,9 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AgentCommand, Event, Host
|
||||
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.win_admin_settings import get_effective_win_admin_config
|
||||
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_for_host
|
||||
from app.services.winrm_connect import (
|
||||
WinRmCmdResult,
|
||||
run_winrm_logoff,
|
||||
@@ -20,23 +21,28 @@ from app.services.winrm_connect import (
|
||||
)
|
||||
|
||||
|
||||
def _require_win_admin(db: Session):
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
def _require_win_admin(db: Session, host: Host | None = None):
|
||||
if host is not None:
|
||||
cfg = get_effective_win_admin_for_host(db, host)
|
||||
else:
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def _require_rdg_flap(db: Session, event: Event) -> None:
|
||||
flap, _, qwinsta_id = resolve_rdg_flap_summary(db, event)
|
||||
if flap and qwinsta_id == event.id:
|
||||
def _require_rdg_client_qwinsta(db: Session, event: Event) -> None:
|
||||
if event_supports_rdg_client_qwinsta(event, db):
|
||||
return
|
||||
if event_has_rdg_flap(event):
|
||||
return
|
||||
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Event is not an RD Gateway connection with internal_ip (client workstation)",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_client(db: Session, event: Event) -> Host:
|
||||
@@ -91,9 +97,9 @@ def _persist_command(
|
||||
|
||||
|
||||
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
||||
_require_rdg_flap(db, event)
|
||||
cfg = _require_win_admin(db)
|
||||
_require_rdg_client_qwinsta(db, event)
|
||||
client_host = _resolve_client(db, event)
|
||||
cfg = _require_win_admin(db, client_host)
|
||||
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
@@ -134,9 +140,9 @@ def execute_logoff_via_winrm(
|
||||
session_id: int,
|
||||
requested_by: str,
|
||||
) -> AgentCommand:
|
||||
_require_rdg_flap(db, event)
|
||||
cfg = _require_win_admin(db)
|
||||
_require_rdg_client_qwinsta(db, event)
|
||||
client_host = _resolve_client(db, event)
|
||||
cfg = _require_win_admin(db, client_host)
|
||||
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Correlate RDG 303/303-failed with workstation rdp.login.success (1149)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta, timezone
|
||||
|
||||
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,
|
||||
RDG_SUCCESS_TYPE,
|
||||
_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"
|
||||
USER_ENRICHED_FROM_RDG_EVENT_ID_KEY = "user_enriched_from_rdg_event_id"
|
||||
|
||||
# RCM 1149 via RD Gateway often has empty Param1/Param2; RDG 302 has the account.
|
||||
RDG_LOGIN_USER_ENRICH_WINDOW = timedelta(minutes=5)
|
||||
|
||||
WORKSTATION_LOGIN_TYPE = "rdp.login.success"
|
||||
|
||||
|
||||
def _as_utc(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def normalize_sam_account(user: str) -> str:
|
||||
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 _login_user_missing(event: Event) -> bool:
|
||||
details = _details_dict(event)
|
||||
for key in ("user", "username"):
|
||||
val = details.get(key)
|
||||
if val is not None and str(val).strip() not in ("", "-"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _apply_rdg_user_to_login(login_event: Event, *, rdg_event: Event, rdg_user: str) -> None:
|
||||
details = dict(_details_dict(login_event))
|
||||
details["user"] = rdg_user
|
||||
details[USER_ENRICHED_FROM_RDG_EVENT_ID_KEY] = rdg_event.id
|
||||
login_event.details = details
|
||||
flag_modified(login_event, "details")
|
||||
summary = (login_event.summary or "").strip()
|
||||
if summary.startswith("RCM 1149") and rdg_user not in summary:
|
||||
rest = summary[len("RCM 1149") :].strip()
|
||||
login_event.summary = f"RCM 1149 {rdg_user} {rest}".strip()
|
||||
|
||||
|
||||
def find_rdg_success_for_workstation_login(db: Session, login_event: Event) -> Event | None:
|
||||
"""Nearest RDG 302 for this workstation IP within the enrich window."""
|
||||
if login_event.type != WORKSTATION_LOGIN_TYPE:
|
||||
return None
|
||||
host = login_event.host
|
||||
if host is None or not (host.ipv4 or "").strip():
|
||||
return None
|
||||
workstation_ip = host.ipv4.strip()
|
||||
login_at = _as_utc(login_event.occurred_at)
|
||||
window_start = login_at - RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
window_end = login_at + RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.type == RDG_SUCCESS_TYPE,
|
||||
Event.occurred_at >= window_start,
|
||||
Event.occurred_at <= window_end,
|
||||
Event.id != login_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
best: Event | None = None
|
||||
best_delta: timedelta | None = None
|
||||
for rdg in candidates:
|
||||
if event_internal_ip(rdg) != workstation_ip:
|
||||
continue
|
||||
if not _event_user(rdg):
|
||||
continue
|
||||
delta = abs(_as_utc(rdg.occurred_at) - login_at)
|
||||
if best is None or best_delta is None or delta < best_delta:
|
||||
best = rdg
|
||||
best_delta = delta
|
||||
return best
|
||||
|
||||
|
||||
def enrich_workstation_login_user_from_rdg(db: Session, login_event: Event) -> Event | None:
|
||||
"""Fill details.user when RCM 1149 EventLog left Param1/Param2 empty (seen on some Win10 Pro)."""
|
||||
if login_event.type != WORKSTATION_LOGIN_TYPE:
|
||||
return None
|
||||
if not _login_user_missing(login_event):
|
||||
return None
|
||||
rdg = find_rdg_success_for_workstation_login(db, login_event)
|
||||
if rdg is None:
|
||||
return None
|
||||
rdg_user = _event_user(rdg)
|
||||
if not rdg_user:
|
||||
return None
|
||||
_apply_rdg_user_to_login(login_event, rdg_event=rdg, rdg_user=rdg_user)
|
||||
return login_event
|
||||
|
||||
|
||||
def enrich_empty_login_from_rdg_success(db: Session, rdg_success_event: Event) -> Event | None:
|
||||
"""Backfill empty workstation 1149 when RDG 302 is ingested after it."""
|
||||
if rdg_success_event.type != RDG_SUCCESS_TYPE:
|
||||
return None
|
||||
internal_ip = event_internal_ip(rdg_success_event)
|
||||
rdg_user = _event_user(rdg_success_event)
|
||||
if not internal_ip or not rdg_user:
|
||||
return None
|
||||
client_host = find_windows_host_by_ipv4(db, internal_ip)
|
||||
if client_host is None:
|
||||
return None
|
||||
|
||||
rdg_at = _as_utc(rdg_success_event.occurred_at)
|
||||
window_start = rdg_at - RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
window_end = rdg_at + RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == client_host.id,
|
||||
Event.type == WORKSTATION_LOGIN_TYPE,
|
||||
Event.occurred_at >= window_start,
|
||||
Event.occurred_at <= window_end,
|
||||
Event.id != rdg_success_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
for login in candidates:
|
||||
if not _login_user_missing(login):
|
||||
continue
|
||||
_apply_rdg_user_to_login(login, rdg_event=rdg_success_event, rdg_user=rdg_user)
|
||||
return login
|
||||
return None
|
||||
|
||||
|
||||
def event_closed_by_rdg(event: Event) -> bool:
|
||||
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:
|
||||
from app.services.rdp_session_logoff import event_closed_by_logoff
|
||||
|
||||
details = _details_dict(login_event)
|
||||
if details.get("session_terminated") is True:
|
||||
return True
|
||||
at = details.get(SESSION_TERMINATED_AT_KEY)
|
||||
if at is not None and str(at).strip() != "":
|
||||
return True
|
||||
if event_closed_by_rdg(login_event):
|
||||
return True
|
||||
return event_closed_by_logoff(login_event)
|
||||
|
||||
|
||||
def find_workstation_login_for_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) or "").strip()
|
||||
if login_user in ("", "-"):
|
||||
login_user = ""
|
||||
# RCM 1149 may lack user (empty Param1); still close by workstation IP + open session.
|
||||
if login_user and not users_match_rdg(login_user, rdg_user):
|
||||
continue
|
||||
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,324 @@
|
||||
"""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_for_host
|
||||
|
||||
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_for_host(db, workstation)
|
||||
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,
|
||||
)
|
||||
|
||||
parsed = parse_qwinsta_sessions(qwinsta.stdout, filter_user=user)
|
||||
matched = _sessions_for_user(parsed, user)
|
||||
if not matched and qwinsta.stdout.strip():
|
||||
parsed = parse_qwinsta_sessions(qwinsta.stdout)
|
||||
matched = _sessions_for_user(parsed, user)
|
||||
if not matched:
|
||||
snippet = " ".join(qwinsta.stdout.split())[:240]
|
||||
parsed_note = f", parsed={len(parsed)} session(s)" if parsed else ""
|
||||
message = (
|
||||
f"No matching Windows session for user {user} on {workstation.hostname}"
|
||||
f"{parsed_note}"
|
||||
)
|
||||
if snippet:
|
||||
message = f"{message}; qwinsta: {snippet}"
|
||||
return AutoDisconnectResult(
|
||||
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,
|
||||
)
|
||||
|
||||
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:
|
||||
result = AutoDisconnectResult(
|
||||
ok=False,
|
||||
message=str(exc),
|
||||
trigger_event_id=event.id,
|
||||
)
|
||||
_mark_auto_disconnect(event, result=result)
|
||||
return result
|
||||
|
||||
rdg_end = event if event.type in RDG_END_TYPES else db.get(Event, _stored_flap_pair_id(event) or -1)
|
||||
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
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Correlate direct RDP logoff (Security 4634/4647) with workstation rdp.login.success."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.models import Event
|
||||
from app.services.host_sessions import (
|
||||
SESSION_TERMINATED_AT_KEY,
|
||||
_details_dict,
|
||||
_event_login_user,
|
||||
)
|
||||
from app.services.rdg_workstation_session import (
|
||||
WORKSTATION_LOGIN_TYPE,
|
||||
event_closed_by_rdg,
|
||||
users_match_rdg,
|
||||
)
|
||||
|
||||
SESSION_CLOSED_BY_LOGOFF_AT_KEY = "session_closed_by_logoff_at"
|
||||
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY = "session_closed_by_logoff_event_id"
|
||||
|
||||
LOGOFF_EVENT_TYPE = "rdp.session.logoff"
|
||||
LOGOFF_EVENT_TYPES = frozenset({LOGOFF_EVENT_TYPE})
|
||||
|
||||
|
||||
def event_closed_by_logoff(event: Event) -> bool:
|
||||
details = _details_dict(event)
|
||||
at = details.get(SESSION_CLOSED_BY_LOGOFF_AT_KEY)
|
||||
return at is not None and str(at).strip() != ""
|
||||
|
||||
|
||||
def mark_login_closed_by_logoff(login_event: Event, *, logoff_event: Event) -> None:
|
||||
details = dict(_details_dict(login_event))
|
||||
details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] = logoff_event.occurred_at.isoformat()
|
||||
details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] = logoff_event.id
|
||||
login_event.details = details
|
||||
flag_modified(login_event, "details")
|
||||
|
||||
|
||||
def _login_already_closed(login_event: Event) -> bool:
|
||||
details = _details_dict(login_event)
|
||||
if details.get("session_terminated") is True:
|
||||
return True
|
||||
at = details.get(SESSION_TERMINATED_AT_KEY)
|
||||
if at is not None and str(at).strip() != "":
|
||||
return True
|
||||
if event_closed_by_rdg(login_event):
|
||||
return True
|
||||
return event_closed_by_logoff(login_event)
|
||||
|
||||
|
||||
def find_workstation_login_for_logoff(db: Session, logoff_event: Event) -> Event | None:
|
||||
if logoff_event.type not in LOGOFF_EVENT_TYPES:
|
||||
return None
|
||||
|
||||
logoff_user = _event_login_user(logoff_event)
|
||||
if not logoff_user:
|
||||
return None
|
||||
logoff_at = logoff_event.occurred_at
|
||||
host_id = logoff_event.host_id
|
||||
if host_id is None:
|
||||
return None
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == host_id,
|
||||
Event.type == WORKSTATION_LOGIN_TYPE,
|
||||
Event.occurred_at <= logoff_at,
|
||||
Event.id != logoff_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
logoff_details = _details_dict(logoff_event)
|
||||
logoff_ip = str(logoff_details.get("ip_address") or "").strip()
|
||||
|
||||
for login in candidates:
|
||||
if _login_already_closed(login):
|
||||
continue
|
||||
login_user = _event_login_user(login)
|
||||
if not users_match_rdg(login_user, logoff_user):
|
||||
continue
|
||||
if logoff_ip and logoff_ip not in ("", "-"):
|
||||
login_ip = str(_details_dict(login).get("ip_address") or "").strip()
|
||||
if login_ip and login_ip not in ("", "-") and login_ip != logoff_ip:
|
||||
continue
|
||||
return login
|
||||
return None
|
||||
|
||||
|
||||
def find_logoff_after_workstation_login(db: Session, login_event: Event) -> Event | None:
|
||||
"""Runtime lookup for historical logins without persisted close flag."""
|
||||
if login_event.type != WORKSTATION_LOGIN_TYPE:
|
||||
return None
|
||||
if _login_already_closed(login_event):
|
||||
return None
|
||||
|
||||
host_id = login_event.host_id
|
||||
if host_id is None:
|
||||
return None
|
||||
|
||||
login_user = _event_login_user(login_event)
|
||||
if not login_user:
|
||||
return None
|
||||
login_at = login_event.occurred_at
|
||||
login_ip = str(_details_dict(login_event).get("ip_address") or "").strip()
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == host_id,
|
||||
Event.type == LOGOFF_EVENT_TYPE,
|
||||
Event.occurred_at >= login_at,
|
||||
Event.id != login_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
).all()
|
||||
|
||||
for logoff in candidates:
|
||||
if not users_match_rdg(_event_login_user(logoff), login_user):
|
||||
continue
|
||||
logoff_ip = str(_details_dict(logoff).get("ip_address") or "").strip()
|
||||
if login_ip and login_ip not in ("", "-") and logoff_ip and logoff_ip not in ("", "-"):
|
||||
if login_ip != logoff_ip:
|
||||
continue
|
||||
return logoff
|
||||
return None
|
||||
|
||||
|
||||
def resolve_workstation_login_closed_by_logoff(db: Session, login_event: Event) -> bool:
|
||||
if event_closed_by_logoff(login_event):
|
||||
return True
|
||||
return find_logoff_after_workstation_login(db, login_event) is not None
|
||||
|
||||
|
||||
def close_workstation_session_for_rdp_logoff(db: Session, logoff_event: Event) -> Event | None:
|
||||
"""On direct RDP logoff, mark matching open workstation login as session-closed."""
|
||||
if logoff_event.type not in LOGOFF_EVENT_TYPES:
|
||||
return None
|
||||
|
||||
login = find_workstation_login_for_logoff(db, logoff_event)
|
||||
if login is None:
|
||||
return None
|
||||
mark_login_closed_by_logoff(login, logoff_event=logoff_event)
|
||||
return login
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Format and extract RDP/RDG session_duration_sec for UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def extract_session_duration_sec(details: dict[str, Any] | None) -> int | None:
|
||||
if not isinstance(details, dict):
|
||||
return None
|
||||
raw = details.get("session_duration_sec")
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if value < 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def format_session_duration(seconds: int) -> str:
|
||||
"""
|
||||
Human-readable session length for lists:
|
||||
- under 24h → HH:MM:SS (05:05:24)
|
||||
- 24h+ → Nд HH:MM:SS (1д 00:01:25)
|
||||
"""
|
||||
if seconds < 0:
|
||||
return "—"
|
||||
days, rem = divmod(int(seconds), 86_400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes, secs = divmod(rem, 60)
|
||||
clock = f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
if days:
|
||||
return f"{days}д {clock}"
|
||||
return clock
|
||||
@@ -6,12 +6,15 @@ import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Host
|
||||
|
||||
SSH_MONITOR_UPDATE_STATE_FILE = "/var/lib/ssh-monitor/agent-update-in-progress"
|
||||
SSH_MONITOR_UPDATE_LOG_PATH = "/var/log/update_script.log"
|
||||
SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
|
||||
SSH_MONITOR_UPDATE_DIR = "/opt/scripts/update"
|
||||
SSH_MONITOR_REPO_NAME = "ssh-monitor"
|
||||
SSH_MONITOR_UPDATE_REPO_URL = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
|
||||
SSH_MONITOR_UPDATE_REPO_URL = "https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
|
||||
SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor"
|
||||
SSH_MONITOR_VERSION_CMD = (
|
||||
f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null "
|
||||
@@ -20,6 +23,17 @@ SSH_MONITOR_VERSION_CMD = (
|
||||
SSH_OUTPUT_MAX_LEN = 12_000
|
||||
_AGENT_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:-SAC)?)", re.IGNORECASE)
|
||||
_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):
|
||||
@@ -117,6 +131,58 @@ def _truncate_output(text: str) -> str:
|
||||
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:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
@@ -124,18 +190,55 @@ def _shell_single_quote(value: str) -> str:
|
||||
def _remote_shell_command(
|
||||
user: str,
|
||||
remote_cmd: str,
|
||||
password: str,
|
||||
*,
|
||||
need_root: bool = False,
|
||||
) -> str:
|
||||
"""Build remote shell command. Sudo only when need_root and user is not root."""
|
||||
login_shell: bool = True,
|
||||
) -> tuple[str, bool]:
|
||||
"""Build remote shell command. Returns (command, needs_sudo_password_on_stdin)."""
|
||||
safe_cmd = _shell_single_quote(remote_cmd)
|
||||
bash_flag = "lc" if login_shell else "c"
|
||||
if user == "root" or not need_root:
|
||||
return f"bash -lc {safe_cmd}"
|
||||
return f"bash -{bash_flag} {safe_cmd}", False
|
||||
return f"sudo -S -p '' bash -{bash_flag} {safe_cmd}", True
|
||||
|
||||
safe_pw = _shell_single_quote(password)
|
||||
inner = f"printf '%s\\n' {safe_pw} | sudo -S -p '' bash -lc {safe_cmd}"
|
||||
return f"bash -lc {_shell_single_quote(inner)}"
|
||||
|
||||
def _configure_ssh_client(client, target: str) -> None:
|
||||
import paramiko
|
||||
|
||||
settings = get_settings()
|
||||
if settings.sac_ssh_auto_add_host_key:
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
return
|
||||
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
client.load_system_host_keys()
|
||||
known_hosts = (settings.sac_ssh_known_hosts_file or "").strip()
|
||||
if known_hosts:
|
||||
try:
|
||||
client.load_host_keys(known_hosts)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _exec_remote_command(
|
||||
client,
|
||||
*,
|
||||
shell_cmd: str,
|
||||
password: str,
|
||||
needs_sudo_password: bool,
|
||||
command_timeout_sec: int,
|
||||
) -> tuple[int, str, str]:
|
||||
if needs_sudo_password:
|
||||
stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec, get_pty=True)
|
||||
stdin.write(f"{password}\n")
|
||||
stdin.flush()
|
||||
stdin.channel.shutdown_write()
|
||||
else:
|
||||
_stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec)
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace"))
|
||||
err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace"))
|
||||
return exit_code, out_text, err_text
|
||||
|
||||
|
||||
def run_ssh_command(
|
||||
@@ -147,6 +250,7 @@ def run_ssh_command(
|
||||
connect_timeout_sec: int = 15,
|
||||
command_timeout_sec: int = 600,
|
||||
need_root: bool = False,
|
||||
login_shell: bool = True,
|
||||
) -> SshCommandResult:
|
||||
target = target.strip()
|
||||
user = user.strip()
|
||||
@@ -158,43 +262,64 @@ def run_ssh_command(
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("paramiko is not installed on SAC server") from exc
|
||||
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
shell_cmd = _remote_shell_command(user, remote_cmd, password, need_root=need_root)
|
||||
shell_cmd, needs_sudo_password = _remote_shell_command(
|
||||
user,
|
||||
remote_cmd,
|
||||
need_root=need_root,
|
||||
login_shell=login_shell,
|
||||
)
|
||||
exit_code: int | None = None
|
||||
out_text = ""
|
||||
err_text = ""
|
||||
|
||||
try:
|
||||
client.connect(
|
||||
hostname=target,
|
||||
username=user,
|
||||
password=password,
|
||||
timeout=connect_timeout_sec,
|
||||
allow_agent=False,
|
||||
look_for_keys=False,
|
||||
)
|
||||
_stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec)
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace"))
|
||||
err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace"))
|
||||
except paramiko.AuthenticationException:
|
||||
for attempt in range(1, _SSH_CONNECT_ATTEMPTS + 1):
|
||||
client = paramiko.SSHClient()
|
||||
try:
|
||||
_configure_ssh_client(client, target)
|
||||
_connect_ssh_client(
|
||||
client,
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
connect_timeout_sec=connect_timeout_sec,
|
||||
)
|
||||
exit_code, out_text, err_text = _exec_remote_command(
|
||||
client,
|
||||
shell_cmd=shell_cmd,
|
||||
password=password,
|
||||
needs_sudo_password=needs_sudo_password,
|
||||
command_timeout_sec=command_timeout_sec,
|
||||
)
|
||||
break
|
||||
except paramiko.AuthenticationException:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH auth failed for {user}@{target}",
|
||||
target=target,
|
||||
)
|
||||
except (socket.timeout, TimeoutError):
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH timeout ({connect_timeout_sec}s connect / {command_timeout_sec}s cmd) to {target}",
|
||||
target=target,
|
||||
)
|
||||
except Exception as exc:
|
||||
if attempt < _SSH_CONNECT_ATTEMPTS and _is_transient_ssh_error(exc):
|
||||
continue
|
||||
hint = _ssh_connect_hint(exc)
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH error ({target}): {exc}{hint}",
|
||||
target=target,
|
||||
)
|
||||
finally:
|
||||
_close_ssh_client(client)
|
||||
else:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH auth failed for {user}@{target}",
|
||||
message=f"SSH error ({target}): repeated transient connection failures",
|
||||
target=target,
|
||||
)
|
||||
except (socket.timeout, TimeoutError):
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH timeout ({connect_timeout_sec}s connect / {command_timeout_sec}s cmd) to {target}",
|
||||
target=target,
|
||||
)
|
||||
except Exception as exc:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH error ({target}): {exc}",
|
||||
target=target,
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
ok = exit_code == 0
|
||||
if ok:
|
||||
@@ -215,6 +340,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(
|
||||
*,
|
||||
target: str,
|
||||
@@ -229,9 +363,12 @@ def test_ssh_connection(
|
||||
remote_cmd="hostname",
|
||||
connect_timeout_sec=timeout_sec,
|
||||
command_timeout_sec=timeout_sec,
|
||||
login_shell=False,
|
||||
)
|
||||
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(
|
||||
ok=True,
|
||||
message=f"SSH OK, hostname={host}",
|
||||
@@ -264,6 +401,7 @@ def probe_ssh_monitor_version(
|
||||
password=password,
|
||||
remote_cmd=SSH_MONITOR_VERSION_CMD,
|
||||
command_timeout_sec=30,
|
||||
login_shell=False,
|
||||
)
|
||||
if result.ok and result.stdout.strip():
|
||||
version = parse_ssh_monitor_version_text(result.stdout)
|
||||
@@ -297,7 +435,7 @@ def _ssh_monitor_update_invoke_command(
|
||||
safe_repo = _shell_single_quote(repo_url.strip())
|
||||
safe_branch = _shell_single_quote((git_branch or "main").strip() or "main")
|
||||
preflight = _ssh_monitor_preflight_git_remote(repo_url)
|
||||
return f"{preflight}; REPO_URL={safe_repo} GIT_BRANCH={safe_branch} {SSH_MONITOR_UPDATE_SCRIPT}"
|
||||
return f"{preflight}; UPDATE_VIA_SAC=1 REPO_URL={safe_repo} GIT_BRANCH={safe_branch} {SSH_MONITOR_UPDATE_SCRIPT}"
|
||||
|
||||
|
||||
def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -> str:
|
||||
@@ -313,7 +451,16 @@ def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -
|
||||
f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && '
|
||||
f'([ -d "$SCRIPT_NAME" ] || git clone -b "$GIT_BRANCH" "$REPO_URL" "$SCRIPT_NAME") && '
|
||||
f'cp "$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" "$INSTALL" && '
|
||||
f'chmod 750 "$INSTALL" && REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL"'
|
||||
f'chmod 750 "$INSTALL" && UPDATE_VIA_SAC=1 REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL" --deploy'
|
||||
)
|
||||
|
||||
|
||||
def _ssh_monitor_mark_update_begin_prefix() -> str:
|
||||
"""State-file до updater: lifecycle/sudo на агенте глушатся раньше bootstrap."""
|
||||
return (
|
||||
f"mkdir -p /var/lib/ssh-monitor && "
|
||||
f"date +%s > {SSH_MONITOR_UPDATE_STATE_FILE} && "
|
||||
f"chmod 600 {SSH_MONITOR_UPDATE_STATE_FILE} 2>/dev/null; "
|
||||
)
|
||||
|
||||
|
||||
@@ -339,6 +486,7 @@ def run_ssh_monitor_update(
|
||||
password=password,
|
||||
remote_cmd=check_cmd,
|
||||
command_timeout_sec=30,
|
||||
login_shell=False,
|
||||
)
|
||||
if not probe.ok:
|
||||
return SshCommandResult(
|
||||
@@ -360,9 +508,10 @@ def run_ssh_monitor_update(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=bootstrap_cmd,
|
||||
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + bootstrap_cmd,
|
||||
command_timeout_sec=900,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
bootstrapped = True
|
||||
else:
|
||||
@@ -370,9 +519,10 @@ def run_ssh_monitor_update(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=update_cmd,
|
||||
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + update_cmd,
|
||||
command_timeout_sec=900,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
if not updated.ok:
|
||||
prefix = "Updater bootstrap failed" if bootstrapped else "SSH update failed"
|
||||
@@ -410,3 +560,29 @@ def run_ssh_monitor_update(
|
||||
exit_code=updated.exit_code,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def tail_ssh_monitor_update_log(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
lines: int = 120,
|
||||
) -> str:
|
||||
"""Хвост /var/log/update_script.log на удалённом хосте (для live-лога в SAC UI)."""
|
||||
safe_lines = max(20, min(int(lines), 400))
|
||||
remote_cmd = (
|
||||
f"test -r {SSH_MONITOR_UPDATE_LOG_PATH} && "
|
||||
f"tail -n {safe_lines} {SSH_MONITOR_UPDATE_LOG_PATH} 2>/dev/null || true"
|
||||
)
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=remote_cmd,
|
||||
command_timeout_sec=25,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
text = (result.stdout or "").strip()
|
||||
return _truncate_output(text)
|
||||
|
||||
@@ -544,24 +544,31 @@ def format_event_mobile_push(event: Event) -> tuple[str, str]:
|
||||
|
||||
|
||||
def _format_rdg_html(event: Event) -> str:
|
||||
from app.services.rdg_display import build_rdg_display
|
||||
|
||||
details = _details_dict(event)
|
||||
display = build_rdg_display(event)
|
||||
if event.type.endswith(".success"):
|
||||
header = "✅ RD Gateway: подключение"
|
||||
header = "✅ RDS через RD Gateway"
|
||||
elif event.type.endswith(".disconnected"):
|
||||
header = "ℹ️ RD Gateway: отключение"
|
||||
header = "ℹ️ RDS отключение (RD Gateway)"
|
||||
else:
|
||||
header = "❌ RD Gateway: ошибка"
|
||||
header = "❌ RDS ошибка (RD Gateway)"
|
||||
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("🏢", "Хост", 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, "internal_ip", default="-")))
|
||||
msg += _line("🖥️", "Рабочий ПК", html_escape(_detail(details, "internal_ip", default="-")))
|
||||
err = _detail(details, "gateway_error_code", "error_code", default="")
|
||||
if err != "-":
|
||||
msg += _line("⚠️", "Код ошибки", html_escape(err))
|
||||
dur = _detail(details, "session_duration_sec", default="")
|
||||
if dur not in ("-", "0", ""):
|
||||
msg += _line("⏱️", "Длительность", f"{html_escape(dur)} с")
|
||||
from app.services.session_duration import extract_session_duration_sec, format_session_duration
|
||||
|
||||
dur_sec = extract_session_duration_sec(details if isinstance(details, dict) else None)
|
||||
if dur_sec is not None and dur_sec > 0:
|
||||
msg += _line("⏱️", "Длительность", html_escape(format_session_duration(dur_sec)))
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
win_id = _detail(details, "event_id_windows", default="")
|
||||
if win_id != "-":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Effective Windows domain admin credentials (DB overrides env)."""
|
||||
"""Effective Windows domain admin credentials (host → DB → env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.host import Host
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
|
||||
@@ -14,7 +15,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
class WinAdminConfig:
|
||||
user: str
|
||||
password: str
|
||||
source: str # env | db
|
||||
source: str # host | env | db
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
@@ -92,3 +93,17 @@ def upsert_win_admin_settings(
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_win_admin_config(db)
|
||||
|
||||
|
||||
def get_effective_win_admin_for_host(db: Session, host: Host) -> WinAdminConfig:
|
||||
"""Prefer per-host mgmt_* when both user and password are set; else global Settings/env."""
|
||||
host_user = normalize_win_admin_user((host.mgmt_user or "").strip())
|
||||
host_password = (host.mgmt_password or "").strip()
|
||||
if host_user and host_password:
|
||||
return WinAdminConfig(user=host_user, password=host_password, source="host")
|
||||
|
||||
global_cfg = get_effective_win_admin_config(db)
|
||||
# Allow host to override only the username, still using global password (rare).
|
||||
if host_user and global_cfg.password.strip():
|
||||
return WinAdminConfig(user=host_user, password=global_cfg.password, source="host")
|
||||
return global_cfg
|
||||
|
||||
@@ -20,7 +20,8 @@ _CLIXML_PROGRESS_NOISE = frozenset(
|
||||
)
|
||||
_CLIXML_MARKER = "#< CLIXML"
|
||||
_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 = (
|
||||
"Login_Monitor.ps1",
|
||||
"Sac-Client.ps1",
|
||||
@@ -91,13 +92,20 @@ def _winrm_session(
|
||||
|
||||
operation_timeout_sec = max(5, timeout_sec)
|
||||
read_timeout_sec = operation_timeout_sec + 15
|
||||
endpoint = f"http://{target}:5985/wsman"
|
||||
settings = get_settings()
|
||||
scheme = "https" if settings.sac_winrm_use_https else "http"
|
||||
port = 5986 if settings.sac_winrm_use_https else 5985
|
||||
endpoint = f"{scheme}://{target}:{port}/wsman"
|
||||
cert_validation = (settings.sac_winrm_server_cert_validation or "validate").strip().lower()
|
||||
if cert_validation not in {"validate", "ignore"}:
|
||||
cert_validation = "validate"
|
||||
session = winrm.Session(
|
||||
endpoint,
|
||||
auth=(user, password),
|
||||
transport="ntlm",
|
||||
read_timeout_sec=read_timeout_sec,
|
||||
operation_timeout_sec=operation_timeout_sec,
|
||||
server_cert_validation=cert_validation,
|
||||
)
|
||||
return session, winrm
|
||||
|
||||
@@ -165,12 +173,17 @@ def _winrm_failure_detail(stdout: str, stderr: str, exit_code: int) -> str:
|
||||
stdout_plain = _clixml_to_plain(stdout)
|
||||
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):
|
||||
if candidate and _CLIXML_MARKER not in candidate:
|
||||
line = candidate.strip().splitlines()[0][:2000]
|
||||
if line.startswith("ERROR:"):
|
||||
return line[6:].strip() or line
|
||||
return line
|
||||
lines = [line.strip() for line in candidate.splitlines() if line.strip()]
|
||||
if lines:
|
||||
return lines[-1][:2000]
|
||||
|
||||
if stdout_plain:
|
||||
return stdout_plain[:2000]
|
||||
@@ -380,52 +393,67 @@ def _custom_deploy_body(script_path: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _prepare_staging_body(staging_path: str) -> str:
|
||||
literal = _powershell_literal_path(staging_path)
|
||||
def _rdp_staging_setup_ps() -> str:
|
||||
dirname = _powershell_literal_path(RDP_REMOTE_STAGING_DIRNAME)
|
||||
legacy = _powershell_literal_path(RDP_LEGACY_STAGING)
|
||||
return (
|
||||
f"$staging = '{literal}'\n"
|
||||
"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(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"
|
||||
f"$staging = Join-Path $env:TEMP '{dirname}'\n"
|
||||
f"$legacyStaging = '{legacy}'\n"
|
||||
"if (Test-Path -LiteralPath $legacyStaging) {\n"
|
||||
" Remove-Item -LiteralPath $legacyStaging -Recurse -Force -ErrorAction SilentlyContinue\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:
|
||||
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}"
|
||||
|
||||
|
||||
def _download_bundle_body(staging_path: str, bundle_url: str) -> str:
|
||||
staging = _powershell_literal_path(staging_path)
|
||||
def _download_bundle_body(bundle_url: str) -> str:
|
||||
url = _powershell_literal_path(bundle_url)
|
||||
return (
|
||||
f"$staging = '{staging}'\n"
|
||||
f"$url = '{url}'\n"
|
||||
"$zip = Join-Path $staging 'sac-rdp-bundle.zip'\n"
|
||||
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n"
|
||||
"Write-Output \"Downloading bundle: $url\"\n"
|
||||
"Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n"
|
||||
"Add-Type -AssemblyName System.IO.Compression.FileSystem\n"
|
||||
"[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging)\n"
|
||||
"Remove-Item -LiteralPath $zip -Force\n"
|
||||
"Write-Output \"Bundle extracted to $staging\"\n"
|
||||
_rdp_staging_setup_ps()
|
||||
+ "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n"
|
||||
+ f"$url = '{url}'\n"
|
||||
+ "$zip = Join-Path $env:TEMP ('sac-rdp-bundle-' + [Guid]::NewGuid().ToString() + '.zip')\n"
|
||||
+ "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n"
|
||||
+ "Write-Output \"Downloading bundle: $url\"\n"
|
||||
+ "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n"
|
||||
+ "if (-not (Test-Path -LiteralPath $zip)) { throw 'Bundle download produced no file' }\n"
|
||||
+ "$zipSize = (Get-Item -LiteralPath $zip).Length\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"
|
||||
)
|
||||
|
||||
|
||||
@@ -459,7 +487,7 @@ def run_winrm_rdp_monitor_update(
|
||||
)
|
||||
|
||||
effective_repo = (
|
||||
repo_url or "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
|
||||
repo_url or "https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
|
||||
).strip()
|
||||
effective_branch = (git_branch or "main").strip() or "main"
|
||||
if not effective_repo:
|
||||
@@ -486,7 +514,6 @@ def run_winrm_rdp_monitor_update(
|
||||
target=target,
|
||||
)
|
||||
|
||||
staging = RDP_REMOTE_STAGING
|
||||
zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES)
|
||||
if not zip_bytes:
|
||||
return WinRmCmdResult(
|
||||
@@ -496,12 +523,13 @@ def run_winrm_rdp_monitor_update(
|
||||
)
|
||||
bundle_token = register_rdp_bundle_zip(zip_bytes)
|
||||
bundle_url = _bundle_download_url(bundle_token)
|
||||
staging_label = f"%TEMP%\\{RDP_REMOTE_STAGING_DIRNAME}"
|
||||
|
||||
prep = run_winrm_ps(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
script=_wrap_powershell_body(_prepare_staging_body(staging)),
|
||||
script=_wrap_powershell_body(_prepare_staging_body()),
|
||||
timeout_sec=120,
|
||||
)
|
||||
if not prep.ok:
|
||||
@@ -511,7 +539,7 @@ def run_winrm_rdp_monitor_update(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
script=_wrap_powershell_body(_download_bundle_body(staging, bundle_url)),
|
||||
script=_wrap_powershell_body(_download_bundle_body(bundle_url)),
|
||||
timeout_sec=300,
|
||||
)
|
||||
if not download.ok:
|
||||
@@ -519,7 +547,7 @@ def run_winrm_rdp_monitor_update(
|
||||
ok=False,
|
||||
message=(
|
||||
f"Failed to download RDP bundle on client: {download.message} "
|
||||
f"(URL: {bundle_url})"
|
||||
"(bundle URL omitted from logs)"
|
||||
),
|
||||
target=target,
|
||||
stdout="\n\n".join(part.strip() for part in [prep.stdout, download.stdout] if part.strip()),
|
||||
@@ -531,10 +559,10 @@ def run_winrm_rdp_monitor_update(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
script=_wrap_powershell_body(_deploy_from_staging_body(staging)),
|
||||
script=_wrap_powershell_body(_deploy_from_staging_body()),
|
||||
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(
|
||||
part.strip()
|
||||
for part in [header, prep.stdout, download.stdout, deploy.stdout]
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Escape user input for SQL ILIKE patterns."""
|
||||
|
||||
|
||||
def escape_ilike_pattern(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return text
|
||||
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
|
||||
APP_NAME = "Security Alert Center"
|
||||
APP_VERSION = "0.20.26"
|
||||
APP_VERSION = "0.5.17"
|
||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""SQLite in-memory fixtures for API tests."""
|
||||
"""SQLite in-memory fixtures for API tests."""
|
||||
|
||||
import os
|
||||
|
||||
# Must be set before app.database imports create_engine
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
os.environ.setdefault("SAC_BOOTSTRAP_API_KEY", "sac_test_key_for_pytest_only")
|
||||
os.environ.setdefault("SAC_SECURITY_ENFORCE", "false")
|
||||
os.environ.setdefault("JWT_SECRET", "pytest-jwt-secret-not-for-production-use")
|
||||
os.environ.setdefault("SAC_SSH_AUTO_ADD_HOST_KEY", "true")
|
||||
os.environ.setdefault("SAC_HOST_SILENCE_SCAN_ENABLED", "false")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -120,6 +124,7 @@ def client(db_session, db_engine, monkeypatch):
|
||||
monkeypatch.setenv("SAC_REMOTE_ACTION_INLINE", "1")
|
||||
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
|
||||
monkeypatch.setattr("app.main.bootstrap_users", lambda: None)
|
||||
monkeypatch.setattr("app.main.bootstrap_stale_remote_actions", lambda: None)
|
||||
|
||||
test_session_local = sessionmaker(bind=db_engine, autocommit=False, autoflush=False)
|
||||
monkeypatch.setattr("app.services.host_remote_actions.SessionLocal", test_session_local)
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.services.agent_version import reference_agent_versions_by_product
|
||||
|
||||
|
||||
def test_normalize_git_repo_url():
|
||||
assert normalize_git_repo_url("git.kalinamall.ru/PapaTramp/ssh-monitor").endswith(
|
||||
assert normalize_git_repo_url("git.papatramp.ru/PapaTramp/ssh-monitor").endswith(
|
||||
"ssh-monitor.git"
|
||||
)
|
||||
assert normalize_git_repo_url("https://example.com/repo.git") == "https://example.com/repo.git"
|
||||
|
||||
@@ -224,3 +224,69 @@ def test_api_agent_update_fallback_ssh(jwt_headers, client, db_session, monkeypa
|
||||
job = wait_remote_job(client, host.id, jwt_headers)
|
||||
assert job["ok"] is True
|
||||
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_get_remote_action_status_ignores_stale_running_payload(db_session):
|
||||
from app.services.host_remote_actions import get_remote_action_status
|
||||
|
||||
host = Host(
|
||||
hostname="done-linux",
|
||||
os_family="linux",
|
||||
product="ssh-monitor",
|
||||
agent_update_state="success",
|
||||
remote_action={
|
||||
"status": "running",
|
||||
"message": "Выполняется обновление… (лог с хоста)",
|
||||
"output": "=== Script update completed successfully ===",
|
||||
"ok": True,
|
||||
"finished_at": "2026-07-08T02:21:11+00:00",
|
||||
},
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
status = get_remote_action_status(host)
|
||||
assert status["active"] is False
|
||||
assert status["agent_update_state"] == "success"
|
||||
|
||||
|
||||
def test_cancel_host_remote_job_api(jwt_headers, client, db_session):
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Tests for client IP resolution behind reverse proxy."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services.client_ip import client_ip_from_request
|
||||
|
||||
|
||||
def _request(*, client_host: str = "10.0.0.5", xff: str | None = None):
|
||||
headers = {}
|
||||
if xff is not None:
|
||||
headers["x-forwarded-for"] = xff
|
||||
return SimpleNamespace(client=SimpleNamespace(host=client_host), headers=headers)
|
||||
|
||||
|
||||
def test_client_ip_without_forwarded_header():
|
||||
assert client_ip_from_request(_request(client_host="203.0.113.10")) == "203.0.113.10"
|
||||
|
||||
|
||||
def test_client_ip_uses_last_forwarded_hop():
|
||||
req = _request(client_host="127.0.0.1", xff="203.0.113.99, 198.51.100.20")
|
||||
assert client_ip_from_request(req) == "198.51.100.20"
|
||||
|
||||
|
||||
def test_client_ip_ignores_spoofed_first_hop():
|
||||
req = _request(client_host="127.0.0.1", xff="1.2.3.4, 203.0.113.50")
|
||||
assert client_ip_from_request(req) == "203.0.113.50"
|
||||
@@ -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("👤")]
|
||||
assert len(user_lines) == 2
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
put = client.put(
|
||||
"/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
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Version and health contract smoke tests (no DB required)."""
|
||||
"""Version and health contract smoke tests (no DB required)."""
|
||||
|
||||
from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
||||
|
||||
|
||||
def test_version_constants():
|
||||
assert APP_VERSION == "0.20.26"
|
||||
assert APP_VERSION == "0.5.0"
|
||||
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.5.0"
|
||||
|
||||
@@ -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,64 @@
|
||||
"""Per-host management credential override."""
|
||||
|
||||
from app.models.host import Host
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.host_mgmt_credentials import get_host_mgmt_access_view, upsert_host_mgmt_credentials
|
||||
|
||||
|
||||
def test_win_admin_for_host_prefers_override(db_session):
|
||||
host = Host(
|
||||
hostname="HOME-PC",
|
||||
os_family="windows",
|
||||
product="rdp-login-monitor",
|
||||
mgmt_user=".\\Admin",
|
||||
mgmt_password="local-secret",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
db_session.refresh(host)
|
||||
|
||||
cfg = get_effective_win_admin_for_host(db_session, host)
|
||||
assert cfg.configured is True
|
||||
assert cfg.source == "host"
|
||||
assert cfg.user == ".\\Admin"
|
||||
assert cfg.password == "local-secret"
|
||||
|
||||
|
||||
def test_linux_admin_for_host_prefers_override(db_session):
|
||||
host = Host(
|
||||
hostname="homeserver",
|
||||
os_family="linux",
|
||||
product="ssh-monitor",
|
||||
mgmt_user="deploy",
|
||||
mgmt_password="ssh-pass",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
db_session.refresh(host)
|
||||
|
||||
cfg = get_effective_linux_admin_for_host(db_session, host)
|
||||
assert cfg.source == "host"
|
||||
assert cfg.user == "deploy"
|
||||
|
||||
|
||||
def test_upsert_and_clear_host_access(db_session):
|
||||
host = Host(hostname="box", os_family="windows", product="rdp-login-monitor")
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
db_session.refresh(host)
|
||||
|
||||
view = upsert_host_mgmt_credentials(
|
||||
db_session,
|
||||
host,
|
||||
user="HOME\\user",
|
||||
password="secret123",
|
||||
)
|
||||
assert view.has_override is True
|
||||
assert view.password_set is True
|
||||
assert view.user == "HOME\\user"
|
||||
|
||||
cleared = upsert_host_mgmt_credentials(db_session, host, clear=True)
|
||||
assert cleared.has_override is False
|
||||
assert cleared.password_set is False
|
||||
assert get_host_mgmt_access_view(db_session, host).user is None
|
||||
@@ -0,0 +1,218 @@
|
||||
"""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,
|
||||
filter_windows_sessions_for_user,
|
||||
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_parse_qwinsta_sessions_disconnected_without_sessionname():
|
||||
"""Disc rows often omit SESSIONNAME; auto flap logoff relies on these."""
|
||||
stdout = """SESSIONNAME USERNAME ID STATE
|
||||
rdp-tcp#0 B26\\s.shelkovaya 2 Active
|
||||
B26\\s.shelkovaya 5 Disc
|
||||
rdp-tcp 65536 Listen
|
||||
services 0 Disc
|
||||
"""
|
||||
rows = parse_qwinsta_sessions(stdout)
|
||||
assert {(r.session_id, r.state.split()[0], r.session_name) for r in rows} == {
|
||||
("2", "Active", "rdp-tcp#0"),
|
||||
("5", "Disc", ""),
|
||||
}
|
||||
filtered = parse_qwinsta_sessions(stdout, filter_user=r"B26\s.shelkovaya")
|
||||
assert [r.session_id for r in filtered] == ["2", "5"]
|
||||
|
||||
|
||||
def test_parse_qwinsta_sessions_filters_domain_user():
|
||||
stdout = """SESSIONNAME USERNAME ID STATE
|
||||
rdp-tcp#1 B26\\bob 3 Active
|
||||
"""
|
||||
rows = parse_qwinsta_sessions(stdout, filter_user=r"B26\bob")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].session_id == "3"
|
||||
|
||||
|
||||
def test_event_supports_session_terminate_types():
|
||||
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_filter_windows_sessions_for_user_matches_domain():
|
||||
rows = [
|
||||
HostSessionRow(session_id="2", user="B26\\papatramp", state="Active"),
|
||||
HostSessionRow(session_id="3", user="B26\\alice", state="Active"),
|
||||
]
|
||||
matched = filter_windows_sessions_for_user(rows, "papatramp")
|
||||
assert len(matched) == 1
|
||||
assert matched[0].session_id == "2"
|
||||
|
||||
|
||||
def test_terminate_session_for_event_windows_already_logged_off(monkeypatch):
|
||||
host = SimpleNamespace(
|
||||
os_family="windows",
|
||||
product="rdp-login-monitor",
|
||||
hostname="BIV-PC",
|
||||
ipv4="192.168.165.39",
|
||||
display_name=None,
|
||||
inventory={},
|
||||
)
|
||||
event = SimpleNamespace(
|
||||
type="rdp.login.success",
|
||||
details={"user": "papatramp"},
|
||||
host=host,
|
||||
)
|
||||
|
||||
def fake_list(_host, _cfg):
|
||||
return [], WinRmCmdResult(ok=True, message="ok", target="BIV-PC", stdout="SESSIONNAME USERNAME ID STATE")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.host_sessions.list_windows_sessions",
|
||||
fake_list,
|
||||
)
|
||||
|
||||
result = terminate_session_for_event(
|
||||
event,
|
||||
linux_cfg=LinuxAdminConfig(user="", password="", source="test"),
|
||||
win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"),
|
||||
)
|
||||
assert result.ok is True
|
||||
assert "уже вышел" in result.message
|
||||
|
||||
|
||||
def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch):
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
_ingest(
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
hb = _ingest(
|
||||
db_session,
|
||||
|
||||
@@ -126,10 +126,55 @@ def test_ingest_daily_report_calls_notify_daily_report(client, auth_headers):
|
||||
"summary": "stats",
|
||||
"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:
|
||||
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
|
||||
assert r.status_code == 201
|
||||
mock_daily.assert_called_once()
|
||||
mock_event.assert_not_called()
|
||||
|
||||
|
||||
def test_ingest_lifecycle_defers_notify(client, auth_headers):
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.v1 import events as events_api
|
||||
|
||||
event_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
**VALID_EVENT,
|
||||
"event_id": event_id,
|
||||
"type": "agent.lifecycle",
|
||||
"title": "started",
|
||||
"summary": "agent started",
|
||||
"details": {"lifecycle": "started"},
|
||||
}
|
||||
with patch.object(events_api, "schedule_notify_lifecycle") as mock_defer:
|
||||
with patch.object(events_api, "notify_lifecycle") as mock_sync:
|
||||
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
|
||||
assert r.status_code == 201
|
||||
mock_defer.assert_called_once()
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
def test_ingest_auth_login_defers_notify(client, auth_headers):
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.v1 import events as events_api
|
||||
|
||||
event_id = str(uuid.uuid4())
|
||||
payload = {
|
||||
**VALID_EVENT,
|
||||
"event_id": event_id,
|
||||
"category": "auth",
|
||||
"type": "ssh.login.success",
|
||||
"severity": "info",
|
||||
"title": "SSH login",
|
||||
"summary": "user@host",
|
||||
}
|
||||
with patch.object(events_api, "schedule_notify_auth_login") as mock_defer:
|
||||
with patch.object(events_api, "notify_auth_login") as mock_sync:
|
||||
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
|
||||
assert r.status_code == 201
|
||||
mock_defer.assert_called_once()
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Ingest body size limit middleware."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware
|
||||
|
||||
|
||||
def test_ingest_body_size_limit_rejects_large_content_length():
|
||||
app = FastAPI()
|
||||
app.add_middleware(IngestBodySizeLimitMiddleware, max_bytes=128)
|
||||
|
||||
@app.post("/api/v1/events")
|
||||
def ingest():
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/events",
|
||||
content=b"x" * 10,
|
||||
headers={"content-length": "256"},
|
||||
)
|
||||
assert response.status_code == 413
|
||||
@@ -174,3 +174,51 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch)
|
||||
assert job["target"] == "ubabuba"
|
||||
if "product_version" in job:
|
||||
assert job["product_version"] is None or isinstance(job["product_version"], str)
|
||||
|
||||
|
||||
def test_host_agent_update_job_shows_log_tail_and_completion_title(
|
||||
jwt_headers, client, db_session, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
|
||||
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
|
||||
from app.config import get_settings
|
||||
from app.models import Host
|
||||
from app.services.ssh_connect import SshCommandResult
|
||||
from tests.test_agent_update import wait_remote_job
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
host = Host(hostname="router", os_family="linux", product="ssh-monitor", ipv4="10.0.0.1")
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
db_session.refresh(host)
|
||||
|
||||
log_tail = (
|
||||
"2026-07-08 14:18:15 INFO: === Script update completed successfully ===\n"
|
||||
"2026-07-08 14:18:15 INFO: завершено успешно (код 0). Итог см. выше\n"
|
||||
)
|
||||
with (
|
||||
patch("app.services.host_remote_actions.run_ssh_monitor_update") as mock_update,
|
||||
patch("app.services.host_remote_actions.tail_ssh_monitor_update_log") as mock_tail,
|
||||
):
|
||||
mock_update.return_value = SshCommandResult(
|
||||
ok=True,
|
||||
message="SSH OK (router), exit 0",
|
||||
target="router",
|
||||
stdout="",
|
||||
exit_code=0,
|
||||
agent_version="2.3.2-SAC",
|
||||
)
|
||||
mock_tail.return_value = log_tail
|
||||
response = client.post(
|
||||
f"/api/v1/hosts/{host.id}/actions/agent-update",
|
||||
headers=jwt_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
job = wait_remote_job(client, host.id, jwt_headers)
|
||||
assert job["ok"] is True
|
||||
assert "готово" in (job.get("title") or "")
|
||||
assert "2.3.2-SAC" in (job.get("message") or "")
|
||||
assert "Script update completed successfully" in (job.get("output") or "")
|
||||
mock_tail.assert_called()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
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():
|
||||
event = Event(
|
||||
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,
|
||||
find_rdg_success_before_end,
|
||||
resolve_rdg_flap_summary,
|
||||
resolve_rdg_qwinsta_enabled,
|
||||
)
|
||||
from app.services.event_summary import event_to_summary
|
||||
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_pair == start.id
|
||||
assert end_qwinsta == end.id
|
||||
assert end_qwinsta == start.id
|
||||
|
||||
assert start_flap is True
|
||||
assert start_pair == end.id
|
||||
assert start_qwinsta == end.id
|
||||
assert start_qwinsta == start.id
|
||||
|
||||
end_summary = event_to_summary(end, db_session)
|
||||
start_summary = event_to_summary(start, db_session)
|
||||
assert end_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
|
||||
|
||||
|
||||
def _flap_event(db_session, *, gw: Host, ws: Host) -> Event:
|
||||
def _rdg_success_event(db_session, *, gw: Host, ws: Host) -> Event:
|
||||
event = Event(
|
||||
event_id="ev-flap-1",
|
||||
event_id="ev-rdg-302",
|
||||
host_id=gw.id,
|
||||
occurred_at=datetime.now(timezone.utc),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
category="auth",
|
||||
type="rdg.connection.disconnected",
|
||||
type="rdg.connection.success",
|
||||
severity="info",
|
||||
title="RD Gateway event 303",
|
||||
title="RD Gateway event 302",
|
||||
summary="",
|
||||
payload={},
|
||||
details={
|
||||
"user": r"B26\papatramp",
|
||||
"internal_ip": ws.ipv4,
|
||||
"rdg_flap": True,
|
||||
},
|
||||
)
|
||||
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.commit()
|
||||
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"
|
||||
|
||||
@@ -96,12 +95,12 @@ def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatc
|
||||
occurred_at=datetime.now(timezone.utc),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
category="auth",
|
||||
type="rdg.connection.disconnected",
|
||||
type="rdg.connection.success",
|
||||
severity="info",
|
||||
title="303",
|
||||
title="302",
|
||||
summary="",
|
||||
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.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)
|
||||
assert response.status_code == 404
|
||||
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,378 @@
|
||||
"""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
|
||||
|
||||
|
||||
def test_empty_rcm1149_user_enriched_from_prior_rdg302(db_session, rdg_settings, rdg_hosts):
|
||||
"""COMM-PC class: EventLog 1149 has empty Param1; RDG 302 already has the account."""
|
||||
ws, _gw = rdg_hosts
|
||||
t0 = datetime.now(timezone.utc)
|
||||
user = r"B26\s.shelkovaya"
|
||||
internal_ip = ws.ipv4
|
||||
|
||||
_ingest(
|
||||
db_session,
|
||||
t0,
|
||||
host={"hostname": "K6A-DC3", "os_family": "windows"},
|
||||
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
|
||||
type="rdg.connection.success",
|
||||
category="auth",
|
||||
severity="warning",
|
||||
title="RDG 302",
|
||||
summary="302",
|
||||
details={"user": user, "internal_ip": internal_ip},
|
||||
)
|
||||
login = _ingest(
|
||||
db_session,
|
||||
t0 + timedelta(seconds=3),
|
||||
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
|
||||
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
|
||||
type="rdp.login.success",
|
||||
category="auth",
|
||||
severity="warning",
|
||||
title="RDP connection (RCM 1149)",
|
||||
summary="RCM 1149 - 192.168.160.40",
|
||||
details={"user": "-", "ip_address": "192.168.160.40", "event_id_windows": 1149},
|
||||
)
|
||||
db_session.refresh(login)
|
||||
|
||||
assert login.details["user"] == user
|
||||
assert login.details.get("user_enriched_from_rdg_event_id")
|
||||
summary = event_to_summary(login, db_session)
|
||||
assert summary.actor_user == user
|
||||
|
||||
|
||||
def test_empty_rcm1149_backfilled_when_rdg302_arrives_later(db_session, rdg_settings, rdg_hosts):
|
||||
ws, _gw = rdg_hosts
|
||||
t0 = datetime.now(timezone.utc)
|
||||
user = r"B26\s.shelkovaya"
|
||||
internal_ip = ws.ipv4
|
||||
|
||||
login = _ingest(
|
||||
db_session,
|
||||
t0 + timedelta(seconds=1),
|
||||
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
|
||||
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
|
||||
type="rdp.login.success",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RDP connection (RCM 1149)",
|
||||
summary="RCM 1149 - 1.2.3.4",
|
||||
details={"user": "-", "event_id_windows": 1149},
|
||||
)
|
||||
assert login.details["user"] == "-"
|
||||
|
||||
_ingest(
|
||||
db_session,
|
||||
t0,
|
||||
host={"hostname": "K6A-DC3", "os_family": "windows"},
|
||||
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
|
||||
type="rdg.connection.success",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RDG 302",
|
||||
summary="302",
|
||||
details={"user": user, "internal_ip": internal_ip},
|
||||
)
|
||||
db_session.refresh(login)
|
||||
assert login.details["user"] == user
|
||||
|
||||
|
||||
def test_empty_user_login_still_closed_by_rdg303(db_session, rdg_settings, rdg_hosts):
|
||||
ws, _gw = rdg_hosts
|
||||
t0 = datetime.now(timezone.utc)
|
||||
user = r"B26\s.shelkovaya"
|
||||
internal_ip = ws.ipv4
|
||||
|
||||
login = _ingest(
|
||||
db_session,
|
||||
t0,
|
||||
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
|
||||
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
|
||||
type="rdp.login.success",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RDP connection (RCM 1149)",
|
||||
summary="RCM 1149",
|
||||
details={"user": "-", "event_id_windows": 1149},
|
||||
)
|
||||
end = _ingest(
|
||||
db_session,
|
||||
t0 + timedelta(minutes=5),
|
||||
host={"hostname": "K6A-DC3", "os_family": "windows"},
|
||||
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
|
||||
type="rdg.connection.disconnected",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="303",
|
||||
summary="303",
|
||||
details={"user": user, "internal_ip": internal_ip},
|
||||
)
|
||||
db_session.refresh(login)
|
||||
assert login.details.get(SESSION_CLOSED_BY_RDG_AT_KEY) == end.occurred_at.isoformat()
|
||||
assert event_session_terminated(login, db=db_session) is True
|
||||
@@ -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,)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Tests for direct RDP logoff → workstation rdp.login.success correlation."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.services.event_summary import event_to_summary
|
||||
from app.services.host_sessions import event_session_terminated
|
||||
from app.services.ingest import ingest_event
|
||||
from app.services.rdp_session_logoff import (
|
||||
SESSION_CLOSED_BY_LOGOFF_AT_KEY,
|
||||
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY,
|
||||
close_workstation_session_for_rdp_logoff,
|
||||
find_logoff_after_workstation_login,
|
||||
resolve_workstation_login_closed_by_logoff,
|
||||
)
|
||||
from tests.test_ingest import VALID_EVENT
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
base = {
|
||||
**VALID_EVENT,
|
||||
"event_id": str(uuid.uuid4()),
|
||||
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _ingest(db, occurred_at: datetime, **overrides):
|
||||
payload = _payload(**overrides)
|
||||
payload["occurred_at"] = occurred_at.isoformat()
|
||||
event, _ = ingest_event(db, payload)
|
||||
db.flush()
|
||||
return event
|
||||
|
||||
|
||||
def test_logoff_marks_workstation_login_closed_on_ingest(db_session):
|
||||
t0 = datetime.now(timezone.utc)
|
||||
user = r"B26\papatramp"
|
||||
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
|
||||
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
|
||||
|
||||
login = _ingest(
|
||||
db_session,
|
||||
t0 + timedelta(seconds=1),
|
||||
host=host,
|
||||
source=source,
|
||||
type="rdp.login.success",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RDP login",
|
||||
summary="4624",
|
||||
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
|
||||
)
|
||||
logoff = _ingest(
|
||||
db_session,
|
||||
t0 + timedelta(hours=1),
|
||||
host=host,
|
||||
source=source,
|
||||
type="rdp.session.logoff",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RDP session logoff",
|
||||
summary="4634",
|
||||
details={
|
||||
"user": user,
|
||||
"ip_address": "192.168.160.3",
|
||||
"logon_type": 10,
|
||||
"event_id_windows": 4634,
|
||||
},
|
||||
)
|
||||
|
||||
assert login.details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] == logoff.occurred_at.isoformat()
|
||||
assert login.details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] == logoff.id
|
||||
assert event_session_terminated(login, db=db_session) is True
|
||||
assert event_to_summary(login, db_session).session_terminated is True
|
||||
|
||||
|
||||
def test_user_mismatch_does_not_close_login(db_session):
|
||||
t0 = datetime.now(timezone.utc)
|
||||
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
|
||||
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
|
||||
|
||||
login = _ingest(
|
||||
db_session,
|
||||
t0,
|
||||
host=host,
|
||||
source=source,
|
||||
type="rdp.login.success",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RDP login",
|
||||
summary="4624",
|
||||
details={"user": r"B26\Alice"},
|
||||
)
|
||||
logoff = _ingest(
|
||||
db_session,
|
||||
t0 + timedelta(hours=1),
|
||||
host=host,
|
||||
source=source,
|
||||
type="rdp.session.logoff",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RDP session logoff",
|
||||
summary="4634",
|
||||
details={"user": r"B26\Bob", "logon_type": 10, "event_id_windows": 4634},
|
||||
)
|
||||
db_session.refresh(login)
|
||||
|
||||
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
|
||||
assert event_session_terminated(login, db=db_session) is False
|
||||
|
||||
|
||||
def test_ip_mismatch_does_not_close_login_when_both_ips_present(db_session):
|
||||
t0 = datetime.now(timezone.utc)
|
||||
user = r"B26\papatramp"
|
||||
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
|
||||
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
|
||||
|
||||
login = _ingest(
|
||||
db_session,
|
||||
t0,
|
||||
host=host,
|
||||
source=source,
|
||||
type="rdp.login.success",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RDP login",
|
||||
summary="4624",
|
||||
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
|
||||
)
|
||||
logoff = _ingest(
|
||||
db_session,
|
||||
t0 + timedelta(hours=1),
|
||||
host=host,
|
||||
source=source,
|
||||
type="rdp.session.logoff",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RDP session logoff",
|
||||
summary="4634",
|
||||
details={"user": user, "ip_address": "192.168.160.99", "logon_type": 10, "event_id_windows": 4634},
|
||||
)
|
||||
db_session.refresh(login)
|
||||
|
||||
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
|
||||
assert event_session_terminated(login, db=db_session) is False
|
||||
|
||||
|
||||
def test_runtime_resolve_for_historical_login_without_flag(db_session):
|
||||
from app.models import Event, Host
|
||||
|
||||
t0 = datetime.now(timezone.utc)
|
||||
user = r"B26\papatramp"
|
||||
host = Host(
|
||||
hostname="BIV-PC",
|
||||
os_family="windows",
|
||||
product="rdp-login-monitor",
|
||||
ipv4="192.168.165.39",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
login = Event(
|
||||
event_id=str(uuid.uuid4()),
|
||||
host_id=host.id,
|
||||
occurred_at=t0 + timedelta(seconds=1),
|
||||
received_at=t0,
|
||||
category="auth",
|
||||
type="rdp.login.success",
|
||||
severity="info",
|
||||
title="RDP login",
|
||||
summary="4624",
|
||||
payload={},
|
||||
details={"user": "papatramp", "ip_address": "192.168.160.3"},
|
||||
)
|
||||
logoff = Event(
|
||||
event_id=str(uuid.uuid4()),
|
||||
host_id=host.id,
|
||||
occurred_at=t0 + timedelta(hours=2),
|
||||
received_at=t0,
|
||||
category="auth",
|
||||
type="rdp.session.logoff",
|
||||
severity="info",
|
||||
title="4634",
|
||||
summary="4634",
|
||||
payload={},
|
||||
details={"user": user, "ip_address": "192.168.160.3", "event_id_windows": 4634},
|
||||
)
|
||||
db_session.add_all([login, logoff])
|
||||
db_session.commit()
|
||||
|
||||
assert resolve_workstation_login_closed_by_logoff(db_session, login) is True
|
||||
assert find_logoff_after_workstation_login(db_session, login) is not None
|
||||
assert event_to_summary(login, db_session).session_terminated is True
|
||||
|
||||
|
||||
def test_sam_domain_user_match_on_logoff(db_session):
|
||||
t0 = datetime.now(timezone.utc)
|
||||
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
|
||||
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
|
||||
|
||||
login = _ingest(
|
||||
db_session,
|
||||
t0,
|
||||
host=host,
|
||||
source=source,
|
||||
type="rdp.login.success",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RDP login",
|
||||
summary="4624",
|
||||
details={"user": r"B26\papatramp", "logon_type": 10},
|
||||
)
|
||||
_ingest(
|
||||
db_session,
|
||||
t0 + timedelta(minutes=30),
|
||||
host=host,
|
||||
source=source,
|
||||
type="rdp.session.logoff",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RDP session logoff",
|
||||
summary="4647",
|
||||
details={"user": "papatramp", "logon_type": 10, "event_id_windows": 4647},
|
||||
)
|
||||
db_session.refresh(login)
|
||||
|
||||
assert event_session_terminated(login, db=db_session) is True
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Security bootstrap and API key hashing."""
|
||||
|
||||
import hashlib
|
||||
import pytest
|
||||
|
||||
from app.auth.api_key import _legacy_hash_api_key, hash_api_key, verify_api_key_hash
|
||||
from app.security_bootstrap import validate_security_settings
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def test_hash_api_key_uses_hmac(monkeypatch):
|
||||
monkeypatch.setenv("JWT_SECRET", "unit-test-secret")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
raw = "sac_example_key"
|
||||
assert hash_api_key(raw) != _legacy_hash_api_key(raw)
|
||||
assert verify_api_key_hash(raw, hash_api_key(raw))
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_verify_api_key_hash_accepts_legacy_sha256(monkeypatch):
|
||||
monkeypatch.setenv("JWT_SECRET", "unit-test-secret")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
raw = "sac_legacy_key"
|
||||
legacy = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
assert verify_api_key_hash(raw, legacy)
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_validate_security_settings_rejects_weak_jwt():
|
||||
settings = Settings(
|
||||
jwt_secret="change-me-in-production",
|
||||
sac_public_url="https://sac.example.com",
|
||||
cors_origins="https://sac.example.com",
|
||||
sac_security_enforce=True,
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="JWT_SECRET"):
|
||||
validate_security_settings(settings)
|
||||
|
||||
|
||||
def test_validate_security_settings_rejects_wildcard_cors(monkeypatch):
|
||||
settings = Settings(
|
||||
jwt_secret="strong-secret-value",
|
||||
sac_public_url="https://sac.example.com",
|
||||
cors_origins="*",
|
||||
sac_security_enforce=True,
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="CORS_ORIGINS"):
|
||||
validate_security_settings(settings)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Tests for session_duration_sec extract/format."""
|
||||
|
||||
from app.services.session_duration import extract_session_duration_sec, format_session_duration
|
||||
|
||||
|
||||
def test_extract_session_duration_sec():
|
||||
assert extract_session_duration_sec({"session_duration_sec": 18324}) == 18324
|
||||
assert extract_session_duration_sec({"session_duration_sec": "42"}) == 42
|
||||
assert extract_session_duration_sec({"session_duration_sec": ""}) is None
|
||||
assert extract_session_duration_sec({}) is None
|
||||
assert extract_session_duration_sec(None) is None
|
||||
assert extract_session_duration_sec({"session_duration_sec": -1}) is None
|
||||
|
||||
|
||||
def test_format_session_duration():
|
||||
assert format_session_duration(0) == "00:00:00"
|
||||
assert format_session_duration(18324) == "05:05:24"
|
||||
assert format_session_duration(2428) == "00:40:28"
|
||||
assert format_session_duration(86400 + 85) == "1д 00:01:25"
|
||||
assert format_session_duration(2 * 86400 + 3661) == "2д 01:01:01"
|
||||
@@ -31,27 +31,42 @@ def _install_fake_paramiko(monkeypatch, *, connect_side_effect=None, exec_setup=
|
||||
fake = MagicMock()
|
||||
fake.SSHClient = mock_client_cls
|
||||
fake.AutoAddPolicy = MagicMock()
|
||||
fake.RejectPolicy = MagicMock()
|
||||
fake.AuthenticationException = _AuthError
|
||||
monkeypatch.setitem(sys.modules, "paramiko", fake)
|
||||
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, needs_pw = _remote_shell_command("root", "loginctl list-sessions", login_shell=False)
|
||||
assert cmd == "bash -c 'loginctl list-sessions'"
|
||||
assert needs_pw is False
|
||||
|
||||
|
||||
def test_remote_shell_command_non_root_probe_has_no_sudo():
|
||||
cmd = _remote_shell_command("deploy", "hostname", "secret", need_root=False)
|
||||
cmd, needs_pw = _remote_shell_command("deploy", "hostname", need_root=False)
|
||||
assert "sudo" not in cmd
|
||||
assert "hostname" in cmd
|
||||
assert needs_pw is False
|
||||
|
||||
|
||||
def test_remote_shell_command_non_root_privileged_uses_sudo_s():
|
||||
cmd = _remote_shell_command("deploy", "/opt/scripts/update_ssh_monitor.sh", "secret", need_root=True)
|
||||
cmd, needs_pw = _remote_shell_command("deploy", "/opt/scripts/update_ssh_monitor.sh", need_root=True)
|
||||
assert "sudo -S" in cmd
|
||||
assert "secret" in cmd
|
||||
assert "update_ssh_monitor.sh" in cmd
|
||||
assert needs_pw is True
|
||||
assert "secret" not in cmd
|
||||
|
||||
|
||||
def test_remote_shell_command_root_skips_sudo_even_when_need_root():
|
||||
cmd = _remote_shell_command("root", "/opt/scripts/update_ssh_monitor.sh", "secret", need_root=True)
|
||||
cmd, needs_pw = _remote_shell_command("root", "/opt/scripts/update_ssh_monitor.sh", need_root=True)
|
||||
assert "sudo" not in cmd
|
||||
assert needs_pw is False
|
||||
|
||||
|
||||
def test_probe_ssh_connection_non_root_does_not_use_sudo(monkeypatch):
|
||||
@@ -125,6 +140,54 @@ def test_iter_ssh_targets_requires_address():
|
||||
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.RejectPolicy = 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 setup(client):
|
||||
stdout = MagicMock()
|
||||
@@ -260,7 +323,7 @@ def test_run_ssh_monitor_update_runs_script(monkeypatch):
|
||||
target="ubabuba",
|
||||
user="root",
|
||||
password="pw",
|
||||
repo_url="https://git.kalinamall.ru/PapaTramp/ssh-monitor.git",
|
||||
repo_url="https://git.papatramp.ru/PapaTramp/ssh-monitor.git",
|
||||
)
|
||||
assert result.ok is True
|
||||
assert result.agent_version == "2.1.0-SAC"
|
||||
|
||||
@@ -11,7 +11,8 @@ from app.services.rdp_bundle_delivery import (
|
||||
)
|
||||
from app.services.winrm_connect import (
|
||||
RDP_BUNDLE_REQUIRED,
|
||||
RDP_REMOTE_STAGING,
|
||||
RDP_LEGACY_STAGING,
|
||||
RDP_REMOTE_STAGING_DIRNAME,
|
||||
WinRmCmdResult,
|
||||
_clixml_to_plain,
|
||||
_custom_deploy_body,
|
||||
@@ -37,30 +38,40 @@ def test_decode_winrm_bytes_handles_utf8_multibyte():
|
||||
|
||||
|
||||
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 "Get-Content" in script
|
||||
assert "-Encoding UTF8" in script
|
||||
assert RDP_REMOTE_STAGING_DIRNAME in script
|
||||
|
||||
|
||||
def test_prepare_staging_recreates_remote_dir():
|
||||
script = _prepare_staging_body(RDP_REMOTE_STAGING)
|
||||
script = _prepare_staging_body()
|
||||
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():
|
||||
script = _download_bundle_body(
|
||||
RDP_REMOTE_STAGING,
|
||||
"https://sac.example/api/v1/agent/rdp-bundle/token",
|
||||
)
|
||||
assert "Invoke-WebRequest" in script
|
||||
assert "$env:TEMP" in script
|
||||
assert RDP_REMOTE_STAGING_DIRNAME 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
|
||||
|
||||
|
||||
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():
|
||||
script = _deploy_from_staging_body(RDP_REMOTE_STAGING)
|
||||
script = _deploy_from_staging_body()
|
||||
assert "-SourceShareRoot" in script
|
||||
assert "Deploy-LoginMonitor.ps1" in script
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
# Native: /opt/security-alert-center/config/sac-api.env
|
||||
# Native: /opt/security-alert-center/config/sac-api.env
|
||||
# sudo chown sac:sac ... && sudo chmod 600 ...
|
||||
# Пароль в URL — в двойных кавычках. Символ # в пароле допустим только внутри кавычек.
|
||||
# systemd НЕ парсит этот файл — читает приложение (SAC_CONFIG_FILE).
|
||||
|
||||
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
|
||||
# Uvicorn workers (ingest burst). Читает deploy/systemd/sac-api-start.sh; при >1 отключите in-process host_silence scan ниже.
|
||||
SAC_UVICORN_WORKERS=4
|
||||
|
||||
SAC_PUBLIC_URL=https://sac.kalinamall.ru
|
||||
# Опционально: другой базовый 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
|
||||
SAC_SECURITY_ENFORCE=true
|
||||
|
||||
# API key для агентов: Authorization: Bearer <ключ>
|
||||
# python3.12 -c "import secrets; print('sac_'+secrets.token_urlsafe(32))"
|
||||
@@ -65,6 +73,8 @@ SAC_DAILY_REPORT_REQUIRE_ACTIVITY=true
|
||||
SAC_HEARTBEAT_STALE_MINUTES=300
|
||||
|
||||
# Проактивный алерт 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_INTERVAL_MINUTES=5
|
||||
|
||||
@@ -84,6 +94,9 @@ SAC_RDG_FLAP_WINDOW_MIN_SEC=1
|
||||
SAC_RDG_FLAP_WINDOW_MAX_SEC=10
|
||||
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)
|
||||
# SAC_WIN_ADMIN_USER=B26\\Administrator
|
||||
# SAC_WIN_ADMIN_PASSWORD=
|
||||
@@ -100,6 +113,12 @@ SAC_PROBLEMS_RETENTION_DAYS=180
|
||||
SAC_LOGIN_MAX_FAILURES=3
|
||||
SAC_LOGIN_FAILURE_WINDOW_MINUTES=15
|
||||
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)
|
||||
SAC_FCM_ENABLED=false
|
||||
@@ -108,4 +127,13 @@ SAC_FCM_PROJECT_ID=
|
||||
SAC_FCM_SERVICE_ACCOUNT_JSON=
|
||||
SAC_MOBILE_REFRESH_EXPIRE_DAYS=90
|
||||
|
||||
CORS_ORIGINS=*
|
||||
CORS_ORIGINS=https://sac.kalinamall.ru
|
||||
|
||||
# SSH: verify host keys (RejectPolicy). Add keys to file before remote actions.
|
||||
# Комментарий — только отдельной строкой с #. Нельзя: SAC_SSH_AUTO_ADD_HOST_KEY=false ← текст
|
||||
# SAC_SSH_KNOWN_HOSTS_FILE=/opt/security-alert-center/config/ssh_known_hosts
|
||||
# SAC_SSH_AUTO_ADD_HOST_KEY=false
|
||||
|
||||
# WinRM: enable HTTPS listener on clients (5986) before turning on.
|
||||
# SAC_WINRM_USE_HTTPS=false
|
||||
# SAC_WINRM_SERVER_CERT_VALIDATION=validate
|
||||
|
||||
@@ -44,6 +44,19 @@ server {
|
||||
proxy_read_timeout 960s;
|
||||
}
|
||||
|
||||
# Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem.
|
||||
location = /api/v1/events {
|
||||
proxy_pass http://sac_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://sac_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -69,6 +69,19 @@ server {
|
||||
proxy_read_timeout 960s;
|
||||
}
|
||||
|
||||
# Ingest burst: lifecycle/auth deferred в API, но POST всё равно может ждать notify_event/problem.
|
||||
location = /api/v1/events {
|
||||
proxy_pass http://sac_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://sac_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
+42
-5
@@ -56,12 +56,16 @@ fi
|
||||
log "pip install -r requirements.txt"
|
||||
sudo -u "${APP_USER}" "${VENV}/bin/pip" install -q -r "${APP_ROOT}/backend/requirements.txt"
|
||||
|
||||
log "Проверка ${CONFIG_FILE} (pydantic; без bash source — inline-комментарии в значениях ломают deploy)"
|
||||
sudo -u "${APP_USER}" bash -c "
|
||||
export SAC_CONFIG_FILE='${CONFIG_FILE}'
|
||||
cd '${APP_ROOT}/backend'
|
||||
'${VENV}/bin/python' -c 'from app.config import get_settings; get_settings(); print(\"config: OK\")'
|
||||
" || die "Неверный ${CONFIG_FILE}: одна переменная = одна строка, комментарии только отдельной строкой с # (не «false ← …» после значения)"
|
||||
|
||||
log "alembic upgrade head"
|
||||
sudo -u "${APP_USER}" bash -c "
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source '${CONFIG_FILE}'
|
||||
set +a
|
||||
export SAC_CONFIG_FILE='${CONFIG_FILE}'
|
||||
cd '${APP_ROOT}/backend' && '${VENV}/bin/alembic' upgrade head
|
||||
"
|
||||
|
||||
@@ -89,6 +93,16 @@ if [ -f "${APP_ROOT}/deploy/systemd/${SERVICE_NAME}.service" ]; then
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
fi
|
||||
START_SH="${APP_ROOT}/deploy/systemd/sac-api-start.sh"
|
||||
if [ -f "${START_SH}" ]; then
|
||||
sed -i 's/\r$//' "${START_SH}"
|
||||
chmod 755 "${START_SH}"
|
||||
fi
|
||||
for _sh in "${APP_ROOT}"/deploy/sac-deploy.sh "${APP_ROOT}"/deploy/systemd/*.sh; do
|
||||
[ -f "${_sh}" ] || continue
|
||||
sed -i 's/\r$//' "${_sh}"
|
||||
chmod 755 "${_sh}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
log "Проверка DATABASE_URL (как у uvicorn через SAC_CONFIG_FILE)"
|
||||
sudo -u "${APP_USER}" bash -c "
|
||||
@@ -105,8 +119,31 @@ print('db: OK')
|
||||
" || die "PostgreSQL: проверьте DATABASE_URL в ${CONFIG_FILE} и systemctl status postgresql"
|
||||
|
||||
log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)"
|
||||
log "Сброс зависших remote_action (running без worker после restart)"
|
||||
sudo -u "${APP_USER}" bash -c "
|
||||
export SAC_CONFIG_FILE='${CONFIG_FILE}'
|
||||
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 is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active"
|
||||
sleep 2
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
if systemctl is-active --quiet "${SERVICE_NAME}"; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active — journalctl -u ${SERVICE_NAME} -n 40 --no-pager"
|
||||
|
||||
HEALTH_OK=0
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Uvicorn launcher: SAC_UVICORN_WORKERS из sac-api.env (без systemd EnvironmentFile).
|
||||
set -euo pipefail
|
||||
|
||||
APP_ROOT="${SAC_APP_ROOT:-/opt/security-alert-center}"
|
||||
CONFIG_FILE="${SAC_CONFIG_FILE:-${APP_ROOT}/config/sac-api.env}"
|
||||
VENV="${APP_ROOT}/backend/.venv"
|
||||
HOST="127.0.0.1"
|
||||
PORT="8000"
|
||||
WORKERS=4
|
||||
|
||||
_read_env_int() {
|
||||
local key="$1" default="$2" line val
|
||||
[ -f "$CONFIG_FILE" ] || {
|
||||
printf '%s\n' "$default"
|
||||
return 0
|
||||
}
|
||||
line="$(grep -E "^[[:space:]]*${key}=" "$CONFIG_FILE" 2>/dev/null | tail -1)" || {
|
||||
printf '%s\n' "$default"
|
||||
return 0
|
||||
}
|
||||
val="${line#*=}"
|
||||
val="${val#"${val%%[![:space:]]*}"}"
|
||||
val="${val%"${val##*[![:space:]]}"}"
|
||||
val="${val#\"}"
|
||||
val="${val%\"}"
|
||||
val="${val#\'}"
|
||||
val="${val%\'}"
|
||||
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
|
||||
printf '%s\n' "$val"
|
||||
else
|
||||
printf '%s\n' "$default"
|
||||
fi
|
||||
}
|
||||
|
||||
WORKERS="$(_read_env_int SAC_UVICORN_WORKERS 4)"
|
||||
|
||||
exec "${VENV}/bin/uvicorn" app.main:app \
|
||||
--host "$HOST" \
|
||||
--port "$PORT" \
|
||||
--workers "$WORKERS" \
|
||||
--timeout-graceful-shutdown 30
|
||||
@@ -1,6 +1,6 @@
|
||||
[Unit]
|
||||
Description=Security Alert Center API (FastAPI)
|
||||
Documentation=https://git.kalinamall.ru/PapaTramp/security-alert-center
|
||||
Documentation=https://git.papatramp.ru/PapaTramp/security-alert-center
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
Requires=postgresql.service
|
||||
@@ -13,7 +13,7 @@ WorkingDirectory=/opt/security-alert-center/backend
|
||||
# Конфиг читает само приложение (pydantic), не systemd — иначе ломаются пароли с #, $ и т.д.
|
||||
Environment=SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env
|
||||
Environment=PYTHONPATH=/opt/security-alert-center/backend
|
||||
ExecStart=/opt/security-alert-center/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 2 --timeout-graceful-shutdown 30
|
||||
ExecStart=/usr/bin/bash /opt/security-alert-center/deploy/systemd/sac-api-start.sh
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
|
||||
@@ -183,11 +183,13 @@ sequenceDiagram
|
||||
|
||||
## 5. П.2C — Доступ SAC к хостам
|
||||
|
||||
### 5.1. Windows — WinRM
|
||||
### 5.1. Windows — WinRM / учётки
|
||||
|
||||
- **Один доменный admin** в **Настройки → Управление хостами → Windows**: `DOMAIN\user` + password (encrypted).
|
||||
- Override на карточке хоста — опционально позже.
|
||||
- Используется для fallback-обновления и (при необходимости) remote ops; для qwinsta/logoff MVP — **через агента** с теми же creds, переданными в command poll (TLS + API key, не пишутся на диск агента).
|
||||
- **Глобальный доменный admin** в **Настройки → Windows**: `DOMAIN\user` + password (encrypted). Аналогично **Linux admin** для SSH.
|
||||
- **Override на карточке хоста** (**Хосты → WinRM/SSH / доступ к хосту**): `COMPUTER\user` / `.\Administrator` + password (encrypted в `hosts.mgmt_*`). Если пусто — берутся глобальные.
|
||||
- Effective creds: `get_effective_win_admin_for_host` / `get_effective_linux_admin_for_host` (source: `host` | `db` | `env`).
|
||||
- API: `GET` / `PUT /api/v1/hosts/{id}/access` (`user`, `password`, `clear`).
|
||||
- Используются для WinRM/SSH-test, fallback-update, qwinsta/logoff, remote ops; для run_as через агента — те же creds в command poll (TLS + API key, не пишутся на диск агента).
|
||||
|
||||
### 5.2. Linux — bootstrap password → SSH key → удалить password
|
||||
|
||||
@@ -266,7 +268,7 @@ Authorization: Bearer sac_xxx
|
||||
| POST | `/api/v1/events/{id}/actions/logoff` | logoff `{ "session_id": 5 }` |
|
||||
| GET | `/api/v1/events/{id}/actions/{cmd_id}` | Статус / результат |
|
||||
| PATCH | `/api/v1/hosts/{id}/config` | Desired config |
|
||||
| PATCH | `/api/v1/hosts/{id}/access` | Bootstrap / WinRM creds |
|
||||
| GET/PUT | `/api/v1/hosts/{id}/access` | Per-host WinRM/SSH override (mgmt_user/password) |
|
||||
| GET/PATCH | `/api/v1/settings/agent-updates` | Режим A/B, версии, источники |
|
||||
| GET/PATCH | `/api/v1/settings/host-management` | Доменный admin Windows |
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|
||||
|---------|--------|------------|
|
||||
| 4624 успех | `rdp.login.success` | info |
|
||||
| 4625 неудача | `rdp.login.failed` | warning |
|
||||
| 4634 / 4647 выход (прямой RDP, **только рабочая станция**) | `rdp.session.logoff` | info |
|
||||
| RCM **20506** Shadow Control started | `rdp.shadow.control.started` | **warning** |
|
||||
| RCM **20507** Shadow Control stopped | `rdp.shadow.control.stopped` | **warning** |
|
||||
| RCM **20510** Shadow Control permission | `rdp.shadow.control.permission` | **warning** |
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ sudo /opt/sac-deploy.sh
|
||||
|
||||
Краткий **502 Bad Gateway** в UI (~10 с) возможен при перезапуске `sac-api` — список хостов автоматически повторяет запрос; после деплоя обновите страницу.
|
||||
|
||||
**Важно в `sac-api.env`:** `SAC_PUBLIC_URL=https://sac.kalinamall.ru` — нужен для WinRM-обновления RDP (клиент скачивает zip с SAC). API: **2 worker** uvicorn (`deploy/systemd/sac-api.service`).
|
||||
**Важно в `sac-api.env`:** `SAC_PUBLIC_URL=https://sac.kalinamall.ru` — нужен для WinRM-обновления RDP (клиент скачивает zip с SAC). API: **4 worker** uvicorn по умолчанию (`SAC_UVICORN_WORKERS`, `deploy/systemd/sac-api-start.sh`).
|
||||
|
||||
Установка скрипта (один раз): `sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh && sudo chmod 755 /opt/sac-deploy.sh`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.kalinamall.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
|
||||
"$id": "https://git.papatramp.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
|
||||
"title": "Security Alert Center Event v1",
|
||||
"description": "Каноническое событие от ssh-monitor или RDP-login-monitor",
|
||||
"type": "object",
|
||||
@@ -94,6 +94,7 @@
|
||||
"session.logind.new",
|
||||
"rdp.login.success",
|
||||
"rdp.login.failed",
|
||||
"rdp.session.logoff",
|
||||
"rdp.shadow.control.started",
|
||||
"rdp.shadow.control.stopped",
|
||||
"rdp.shadow.control.permission",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Backlog: ingest и массовое обновление агентов
|
||||
|
||||
**Статус:** ToDo (не в текущем релизе).
|
||||
**Контекст:** массовый SSH-update через SAC (10+ хостов) + `sac-deploy` в одно окно → часть POST в ingest «теряется» с точки зрения агента (`SAC POST HTTP :`), flood в Telegram (fallback + watchdog).
|
||||
**Связано:** [runbook-ops.md](runbook-ops.md), [agent-integration.md](agent-integration.md), ssh-monitor [security-roadmap.ru.md](https://git.papatramp.ru/PapaTramp/ssh-monitor/src/branch/main/docs/security-roadmap.ru.md).
|
||||
|
||||
Увеличение `SAC_DB_POOL_SIZE` / defer daily в **0.5.0** лечит штурм суточных отчётов и пул БД; **не** снимает узкие места ниже при одновременном restart многих агентов.
|
||||
|
||||
---
|
||||
|
||||
## Операционно (сразу, без кода)
|
||||
|
||||
- [ ] **Не совмещать** `sudo /opt/sac-deploy.sh` и массовое «Обновить ssh-monitor (SSH)» по многим хостам — сначала deploy SAC, потом агенты (или наоборот).
|
||||
- [ ] Обновлять Linux-хосты **пачками по 2–3**, не все подряд.
|
||||
- [ ] На зрелых хостах перевести **`UseSAC=exclusive`** (нет дубля в Telegram с агента при fallback); watchdog по-прежнему шлёт в Telegram сам.
|
||||
- [ ] Перед первым SSH-update с SAC: **`ssh-keyscan`** в `config/ssh_known_hosts` (см. runbook).
|
||||
- [ ] В `sac-api.env`: только `KEY=value` на строку, комментарии отдельной строкой с `#`.
|
||||
|
||||
---
|
||||
|
||||
## SAC (backend / deploy)
|
||||
|
||||
- [x] **Defer** `notify_lifecycle` и `notify_auth_login` в background (как `report.daily.*` → `schedule_notify_daily_report`), чтобы ingest не ждал Telegram API — **0.5.5**
|
||||
- [x] **Uvicorn workers:** 4 по умолчанию или `SAC_UVICORN_WORKERS` в `sac-api.env` / `sac-api-start.sh` — **0.5.5**
|
||||
- [x] **nginx:** отдельный `location` для `POST /api/v1/events` с увеличенным `proxy_read_timeout` — **0.5.5**
|
||||
- [ ] Опционально: метрики/лог длительности ingest и очереди при burst.
|
||||
- [x] UI: предупреждение при массовом update («N хостов — рекомендуется пачками») — **0.5.5**
|
||||
|
||||
---
|
||||
|
||||
## ssh-monitor (агент)
|
||||
|
||||
- [x] При **shutdown** / `SIGTERM` не увеличивать `sac-fail.count` — **2.3.2-SAC**
|
||||
- [x] После успешного heartbeat сбрасывать fail counter (успешный POST ingest) — уже было
|
||||
- [x] Watchdog: не слать Telegram при штатном restart во время SAC-update (state file `/var/lib/ssh-monitor/agent-update-in-progress` от updater) — **2.2.1-SAC**
|
||||
- [x] Sudo bootstrap/update через SAC: не слать Telegram (`ssh_monitor_sudo_is_sac_maintenance`, state-file раньше) — **2.2.3-SAC**
|
||||
- [x] SAC: suppress `privilege.sudo.command` при `agent_update_state=running` / maintenance command — **0.5.3**
|
||||
- [x] Документировать рекомендуемый `SAC_TIMEOUT_SEC` при тяжёлом ingest — **2.3.2-SAC** (`docs/sac-ingest.ru.md`)
|
||||
|
||||
---
|
||||
|
||||
## Принято / сделано
|
||||
|
||||
- [x] Pool БД 15+25, defer daily push — SAC **0.5.0**
|
||||
- [x] `sac-deploy.sh` без `source` конфига, preflight pydantic — SAC `fa1bd41`
|
||||
- [x] Watchdog: строка `🖥️ Сервер:` в Telegram — ssh-monitor **2.1.9-SAC**
|
||||
|
||||
---
|
||||
|
||||
*После реализации пунктов SAC — bump `APP_VERSION` и runbook.*
|
||||
@@ -24,7 +24,7 @@ Docker удобен для локальной отладки или изолир
|
||||
|
||||
```bash
|
||||
sudo apt install -y git
|
||||
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
sudo chown -R "$USER:$USER" /opt/security-alert-center
|
||||
```
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ nginx -v
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt
|
||||
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
```bash
|
||||
# см. полный чеклист в native-руководстве
|
||||
sudo apt update && sudo apt install -y git postgresql nginx python3.12 python3.12-venv
|
||||
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||
# … PostgreSQL, venv, config/sac-api.env, systemd, nginx
|
||||
```
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ sudo /opt/sac-deploy.sh
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Хост | `ubabuba` / `10.10.36.9` |
|
||||
| Репозиторий | `git.kalinamall.ru/PapaTramp/ssh-monitor` (`main`, есть `sac-client.sh`) |
|
||||
| Репозиторий | `git.papatramp.ru/PapaTramp/ssh-monitor` (`main`, есть `sac-client.sh`) |
|
||||
| `UseSAC` | пилот **exclusive** — см. [pilot-2.1-exclusive.md](pilot-2.1-exclusive.md) |
|
||||
| Сервис | `ssh-monitor.service` — **active** |
|
||||
| `--check-sac` | OK (health + ingest `agent.test` 202) |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user