Compare commits

..

1 Commits

Author SHA1 Message Date
PapaTramp ed4e78f6c3 chore(home): mirror from kalinamall (9883e6a) with papatramp URLs 2026-07-14 20:43:52 +10:00
135 changed files with 9518 additions and 671 deletions
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts must use LF — CRLF in shebang breaks systemd (status=203/EXEC).
*.sh text eol=lf
+8 -7
View File
@@ -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 за 110 с)
- **Оповещения** — 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
View File
@@ -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 110 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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+4 -3
View File
@@ -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:
+155
View File
@@ -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)
+7 -3
View File
@@ -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)
+20 -5
View File
@@ -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(
+37
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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",
+5 -1
View File
@@ -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
View File
@@ -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)
+3
View File
@@ -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))
+5
View File
@@ -33,3 +33,8 @@ class UiSettings(Base):
agent_git_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
agent_git_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
)
+12
View File
@@ -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}
+52
View File
@@ -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")
+17 -6
View File
@@ -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
+1 -1
View File
@@ -27,5 +27,5 @@ def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]:
"config_revision": int(host.agent_config_revision or 0),
"config": 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],
}
+4 -4
View File
@@ -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,
+27
View File
@@ -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]
+68 -2
View File
@@ -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
+1
View File
@@ -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",
+22 -2
View File
@@ -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
+132
View File
@@ -0,0 +1,132 @@
"""Best-effort integration with fail2ban for SSH login bans (OS level, not SAC DB)."""
from __future__ import annotations
import logging
import re
import subprocess
from dataclasses import dataclass
from app.config import get_settings
logger = logging.getLogger(__name__)
_BANNED_IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
@dataclass(frozen=True)
class Fail2banActionResult:
ok: bool
message: str
def _ssh_jail() -> str:
settings = get_settings()
jail = (getattr(settings, "sac_fail2ban_ssh_jail", None) or "sshd").strip()
return jail or "sshd"
def _run_fail2ban(args: list[str], *, timeout: int = 15) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["fail2ban-client", *args],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def fail2ban_available() -> bool:
try:
proc = _run_fail2ban(["ping"], timeout=5)
return proc.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def list_ssh_banned_ips() -> list[str]:
if not fail2ban_available():
return []
jail = _ssh_jail()
try:
proc = _run_fail2ban(["status", jail])
except (OSError, subprocess.SubprocessError) as exc:
logger.warning("fail2ban status failed: %s", exc)
return []
if proc.returncode != 0:
return []
text = (proc.stdout or "") + "\n" + (proc.stderr or "")
ips: list[str] = []
capture = False
for line in text.splitlines():
lower = line.strip().lower()
if "banned ip list" in lower:
capture = True
tail = line.split(":", 1)[-1]
ips.extend(_BANNED_IP_RE.findall(tail))
continue
if capture:
if not line.strip():
break
ips.extend(_BANNED_IP_RE.findall(line))
# preserve order, unique
seen: set[str] = set()
out: list[str] = []
for ip in ips:
if ip not in seen:
seen.add(ip)
out.append(ip)
return out
def unban_ssh_ip(ip_address: str) -> Fail2banActionResult:
ip = (ip_address or "").strip()
if not ip:
return Fail2banActionResult(ok=False, message="empty ip")
if not fail2ban_available():
return Fail2banActionResult(ok=False, message="fail2ban-client недоступен на сервере SAC")
jail = _ssh_jail()
try:
proc = _run_fail2ban(["set", jail, "unbanip", ip])
except (OSError, subprocess.SubprocessError) as exc:
return Fail2banActionResult(ok=False, message=str(exc))
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "unban failed").strip()
return Fail2banActionResult(ok=False, message=err[:500])
return Fail2banActionResult(ok=True, message=f"SSH: IP {ip} разблокирован (jail {jail})")
def sync_fail2ban_ignore_ips(ip_addresses: list[str]) -> Fail2banActionResult:
settings = get_settings()
if not getattr(settings, "sac_fail2ban_sync_enabled", False):
return Fail2banActionResult(ok=True, message="sync отключён (SAC_FAIL2BAN_SYNC_ENABLED=false)")
path = (getattr(settings, "sac_fail2ban_ignoreip_file", None) or "").strip()
if not path:
return Fail2banActionResult(ok=True, message="файл ignoreip не задан (SAC_FAIL2BAN_IGNOREIP_FILE)")
jail = _ssh_jail()
base_ignore = "127.0.0.1/8 ::1"
extra = " ".join(ip for ip in ip_addresses if ip)
ignore_line = f"{base_ignore} {extra}".strip()
content = (
f"# Managed by SAC — login IP whitelist for fail2ban\n"
f"[{jail}]\n"
f"ignoreip = {ignore_line}\n"
)
try:
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
except OSError as exc:
return Fail2banActionResult(ok=False, message=f"не удалось записать {path}: {exc}")
if not fail2ban_available():
return Fail2banActionResult(ok=True, message=f"файл записан ({path}); fail2ban reload пропущен")
try:
proc = _run_fail2ban(["reload"], timeout=30)
except (OSError, subprocess.SubprocessError) as exc:
return Fail2banActionResult(ok=False, message=f"файл записан, reload failed: {exc}")
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "reload failed").strip()
return Fail2banActionResult(ok=False, message=f"файл записан, reload: {err[:300]}")
return Fail2banActionResult(ok=True, message=f"fail2ban ignoreip обновлён ({path})")
+127
View File
@@ -0,0 +1,127 @@
"""Probe remote host and register in SAC before agent deploy (WinRM / SSH)."""
from __future__ import annotations
import re
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Host
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
from app.services.linux_admin_settings import get_effective_linux_admin_config
from app.services.ssh_connect import _is_ipv4, _ssh_output_hostname, test_ssh_connection
from app.services.win_admin_settings import get_effective_win_admin_config
from app.services.winrm_connect import test_winrm_connection
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
class ManualHostAddError(Exception):
pass
def validate_windows_target(target: str) -> str:
text = (target or "").strip()
if not text:
raise ManualHostAddError("Укажите имя Windows-ПК")
if _IPV4_RE.match(text):
raise ManualHostAddError(
"Для Windows укажите имя ПК (NetBIOS/DNS), не IP — WinRM с Kerberos по IP ненадёжен"
)
short = text.split(".")[0].split("\\")[-1].strip()
if not short or " " in short:
raise ManualHostAddError("Некорректное имя хоста")
return short
def validate_linux_target(target: str) -> str:
text = (target or "").strip()
if not text:
raise ManualHostAddError("Укажите IP или hostname Linux-хоста")
return text
def probe_windows_host(db: Session, hostname: str) -> str:
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise ManualHostAddError("Windows domain admin не настроен в SAC")
probe = test_winrm_connection(
target=hostname,
user=cfg.user,
password=cfg.password,
)
if not probe.ok:
raise ManualHostAddError(probe.message)
return (probe.hostname or hostname).strip()
def probe_linux_host(db: Session, target: str) -> tuple[str, str | None]:
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise ManualHostAddError("Linux SSH admin не настроен в SAC")
probe = test_ssh_connection(
target=target,
user=cfg.user,
password=cfg.password,
)
if not probe.ok:
raise ManualHostAddError(probe.message)
hostname = _ssh_output_hostname(probe.stdout) or target
ipv4 = target if _is_ipv4(target) else None
return hostname, ipv4
def get_or_create_manual_host(
db: Session,
*,
hostname: str,
product: str,
os_family: str,
ipv4: str | None = None,
) -> Host:
host = db.scalar(
select(Host).where(Host.hostname == hostname, Host.product == product).limit(1)
)
now = datetime.now(timezone.utc)
if host is None:
host = Host(
hostname=hostname,
os_family=os_family,
product=product,
ipv4=ipv4,
last_seen_at=now,
)
db.add(host)
else:
host.os_family = os_family
if ipv4:
host.ipv4 = ipv4
host.last_seen_at = now
db.flush()
return host
def prepare_manual_host_add(db: Session, *, platform: str, target: str) -> Host:
platform_norm = (platform or "").strip().lower()
if platform_norm == "windows":
win_target = validate_windows_target(target)
hostname = probe_windows_host(db, win_target)
return get_or_create_manual_host(
db,
hostname=hostname,
product=PRODUCT_RDP,
os_family="windows",
)
if platform_norm == "linux":
linux_target = validate_linux_target(target)
hostname, ipv4 = probe_linux_host(db, linux_target)
return get_or_create_manual_host(
db,
hostname=hostname,
product=PRODUCT_SSH,
os_family="linux",
ipv4=ipv4,
)
raise ManualHostAddError("platform должен быть windows или linux")
@@ -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)
+209 -5
View File
@@ -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}
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" or host.agent_update_state == "running"
active = status == "running"
return {
"active": active,
"host_id": host.id,
+578
View File
@@ -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
+134 -26
View File
@@ -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,
)
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(
+13
View File
@@ -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
+16 -2
View File
@@ -1,4 +1,4 @@
"""Effective Linux SSH admin credentials (DB overrides env)."""
"""Effective Linux SSH admin credentials (host → DB → env)."""
from __future__ import annotations
@@ -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
+12 -10
View File
@@ -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
+124 -1
View File
@@ -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()
+1 -1
View File
@@ -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
+132
View File
@@ -0,0 +1,132 @@
"""RD Gateway event labels (access path, UI title/summary)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Event
from app.services.rdg_client_host import find_windows_host_by_ipv4
from app.services.rdg_session_flap import event_internal_ip, resolve_rdg_qwinsta_enabled
RDG_TYPES = frozenset(
{
"rdg.connection.success",
"rdg.connection.disconnected",
"rdg.connection.failed",
}
)
ACCESS_PATH_HAPROXY = "Haproxy-RDG-Comp"
ACCESS_PATH_DIRECT = "RDG-Comp"
@dataclass(frozen=True)
class RdgDisplayInfo:
title: str
summary: str
access_path: str | None
internal_ip: str | None
qwinsta_enabled: bool
def _details_dict(event: Event) -> dict:
raw = event.details
return raw if isinstance(raw, dict) else {}
def _event_external_ip(event: Event) -> str:
details = _details_dict(event)
for key in ("external_ip", "source_ip", "ip_address"):
val = details.get(key)
if val is not None and str(val).strip():
return str(val).strip()
return ""
def _parse_haproxy_ips() -> frozenset[str]:
raw = (get_settings().sac_rdg_haproxy_external_ips or "").strip()
if not raw:
return frozenset()
parts = [p.strip() for p in raw.replace(";", ",").split(",")]
return frozenset(p for p in parts if p)
def classify_rdg_access_path(external_ip: str) -> str | None:
ip = (external_ip or "").strip()
if not ip or ip in ("-", "N/A"):
return None
if ip in _parse_haproxy_ips():
return ACCESS_PATH_HAPROXY
return ACCESS_PATH_DIRECT
def _windows_event_label(event: Event) -> str:
details = _details_dict(event)
win_id = details.get("event_id_windows")
if win_id is None:
return ""
text = str(win_id).strip()
return f"event {text}" if text else ""
def _action_label(event: Event) -> str:
if event.type == "rdg.connection.success":
return "подключение"
if event.type == "rdg.connection.disconnected":
return "отключение"
return "ошибка"
def build_rdg_display(event: Event, db: Session | None = None) -> RdgDisplayInfo | None:
if event.type not in RDG_TYPES:
return None
details = _details_dict(event)
internal_ip = event_internal_ip(event)
external_ip = _event_external_ip(event)
access_path = classify_rdg_access_path(external_ip)
user = str(details.get("user") or "").strip()
gateway = event.host.hostname if event.host else ""
client_label = internal_ip or ""
if db and internal_ip:
client_host = find_windows_host_by_ipv4(db, internal_ip)
if client_host is not None:
client_label = f"{client_host.hostname} ({internal_ip})"
path_note = f" ({access_path})" if access_path else ""
win_note = _windows_event_label(event)
action = _action_label(event)
title = f"RDS {action}{client_label}{path_note}"
if win_note:
title = f"{title} · {win_note}"
summary_parts: list[str] = []
if user:
summary_parts.append(user)
if external_ip:
summary_parts.append(f"внешний {external_ip}")
summary_parts.append(f"шлюз {gateway}")
if win_note:
summary_parts.append(win_note)
summary = " · ".join(summary_parts)
qwinsta_enabled = resolve_rdg_qwinsta_enabled(db, event)
return RdgDisplayInfo(
title=title,
summary=summary,
access_path=access_path,
internal_ip=internal_ip or None,
qwinsta_enabled=qwinsta_enabled,
)
def event_supports_rdg_client_qwinsta(event: Event, db: Session | None = None) -> bool:
from app.services.rdg_session_flap import resolve_rdg_qwinsta_enabled
return resolve_rdg_qwinsta_enabled(db, event)
+118 -3
View File
@@ -47,6 +47,15 @@ def _internal_ips_compatible(end_event: Event, success_event: Event) -> bool:
return end_ip == success_ip
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)
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
+20 -14
View File
@@ -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):
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
+33
View File
@@ -0,0 +1,33 @@
"""RDP / RDG flap auto-disconnect settings (ui_settings singleton)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
@dataclass(frozen=True)
class RdpFlapSettings:
auto_disconnect: bool
source: str = "db"
def get_effective_rdp_flap_settings(db: Session) -> RdpFlapSettings:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
return RdpFlapSettings(auto_disconnect=False, source="default")
return RdpFlapSettings(auto_disconnect=bool(row.auto_rdp_flap_disconnect), source="db")
def upsert_rdp_flap_settings(db: Session, *, auto_disconnect: bool) -> RdpFlapSettings:
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
if row is None:
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
db.add(row)
row.auto_rdp_flap_disconnect = bool(auto_disconnect)
db.commit()
db.refresh(row)
return get_effective_rdp_flap_settings(db)
+148
View File
@@ -0,0 +1,148 @@
"""Correlate direct RDP logoff (Security 4634/4647) with workstation rdp.login.success."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.models import Event
from app.services.host_sessions import (
SESSION_TERMINATED_AT_KEY,
_details_dict,
_event_login_user,
)
from app.services.rdg_workstation_session import (
WORKSTATION_LOGIN_TYPE,
event_closed_by_rdg,
users_match_rdg,
)
SESSION_CLOSED_BY_LOGOFF_AT_KEY = "session_closed_by_logoff_at"
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY = "session_closed_by_logoff_event_id"
LOGOFF_EVENT_TYPE = "rdp.session.logoff"
LOGOFF_EVENT_TYPES = frozenset({LOGOFF_EVENT_TYPE})
def event_closed_by_logoff(event: Event) -> bool:
details = _details_dict(event)
at = details.get(SESSION_CLOSED_BY_LOGOFF_AT_KEY)
return at is not None and str(at).strip() != ""
def mark_login_closed_by_logoff(login_event: Event, *, logoff_event: Event) -> None:
details = dict(_details_dict(login_event))
details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] = logoff_event.occurred_at.isoformat()
details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] = logoff_event.id
login_event.details = details
flag_modified(login_event, "details")
def _login_already_closed(login_event: Event) -> bool:
details = _details_dict(login_event)
if details.get("session_terminated") is True:
return True
at = details.get(SESSION_TERMINATED_AT_KEY)
if at is not None and str(at).strip() != "":
return True
if event_closed_by_rdg(login_event):
return True
return event_closed_by_logoff(login_event)
def find_workstation_login_for_logoff(db: Session, logoff_event: Event) -> Event | None:
if logoff_event.type not in LOGOFF_EVENT_TYPES:
return None
logoff_user = _event_login_user(logoff_event)
if not logoff_user:
return None
logoff_at = logoff_event.occurred_at
host_id = logoff_event.host_id
if host_id is None:
return None
candidates = db.scalars(
select(Event)
.where(
Event.host_id == host_id,
Event.type == WORKSTATION_LOGIN_TYPE,
Event.occurred_at <= logoff_at,
Event.id != logoff_event.id,
)
.order_by(Event.occurred_at.desc())
).all()
logoff_details = _details_dict(logoff_event)
logoff_ip = str(logoff_details.get("ip_address") or "").strip()
for login in candidates:
if _login_already_closed(login):
continue
login_user = _event_login_user(login)
if not users_match_rdg(login_user, logoff_user):
continue
if logoff_ip and logoff_ip not in ("", "-"):
login_ip = str(_details_dict(login).get("ip_address") or "").strip()
if login_ip and login_ip not in ("", "-") and login_ip != logoff_ip:
continue
return login
return None
def find_logoff_after_workstation_login(db: Session, login_event: Event) -> Event | None:
"""Runtime lookup for historical logins without persisted close flag."""
if login_event.type != WORKSTATION_LOGIN_TYPE:
return None
if _login_already_closed(login_event):
return None
host_id = login_event.host_id
if host_id is None:
return None
login_user = _event_login_user(login_event)
if not login_user:
return None
login_at = login_event.occurred_at
login_ip = str(_details_dict(login_event).get("ip_address") or "").strip()
candidates = db.scalars(
select(Event)
.where(
Event.host_id == host_id,
Event.type == LOGOFF_EVENT_TYPE,
Event.occurred_at >= login_at,
Event.id != login_event.id,
)
.order_by(Event.occurred_at.asc())
).all()
for logoff in candidates:
if not users_match_rdg(_event_login_user(logoff), login_user):
continue
logoff_ip = str(_details_dict(logoff).get("ip_address") or "").strip()
if login_ip and login_ip not in ("", "-") and logoff_ip and logoff_ip not in ("", "-"):
if login_ip != logoff_ip:
continue
return logoff
return None
def resolve_workstation_login_closed_by_logoff(db: Session, login_event: Event) -> bool:
if event_closed_by_logoff(login_event):
return True
return find_logoff_after_workstation_login(db, login_event) is not None
def close_workstation_session_for_rdp_logoff(db: Session, logoff_event: Event) -> Event | None:
"""On direct RDP logoff, mark matching open workstation login as session-closed."""
if logoff_event.type not in LOGOFF_EVENT_TYPES:
return None
login = find_workstation_login_for_logoff(db, logoff_event)
if login is None:
return None
mark_login_closed_by_logoff(login, logoff_event=logoff_event)
return login
+37
View File
@@ -0,0 +1,37 @@
"""Format and extract RDP/RDG session_duration_sec for UI."""
from __future__ import annotations
from typing import Any
def extract_session_duration_sec(details: dict[str, Any] | None) -> int | None:
if not isinstance(details, dict):
return None
raw = details.get("session_duration_sec")
if raw is None or raw == "":
return None
try:
value = int(raw)
except (TypeError, ValueError):
return None
if value < 0:
return None
return value
def format_session_duration(seconds: int) -> str:
"""
Human-readable session length for lists:
- under 24h HH:MM:SS (05:05:24)
- 24h+ 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
+207 -31
View File
@@ -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,23 +262,35 @@ 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)
try:
client.connect(
hostname=target,
username=user,
password=password,
timeout=connect_timeout_sec,
allow_agent=False,
look_for_keys=False,
shell_cmd, needs_sudo_password = _remote_shell_command(
user,
remote_cmd,
need_root=need_root,
login_shell=login_shell,
)
_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"))
exit_code: int | None = None
out_text = ""
err_text = ""
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,
@@ -188,13 +304,22 @@ def run_ssh_command(
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}",
message=f"SSH error ({target}): {exc}{hint}",
target=target,
)
finally:
client.close()
_close_ssh_client(client)
else:
return SshCommandResult(
ok=False,
message=f"SSH error ({target}): repeated transient connection failures",
target=target,
)
ok = exit_code == 0
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)
+15 -8
View File
@@ -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 != "-":
+17 -2
View File
@@ -1,4 +1,4 @@
"""Effective Windows domain admin credentials (DB overrides env)."""
"""Effective Windows domain admin credentials (host → DB → env)."""
from __future__ import annotations
@@ -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
+76 -48
View File
@@ -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]
+8
View File
@@ -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("_", "\\_")
+2 -2
View File
@@ -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}"
+6 -1
View File
@@ -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)
+1 -1
View File
@@ -14,7 +14,7 @@ from app.services.agent_version import reference_agent_versions_by_product
def test_normalize_git_repo_url():
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"
+66
View File
@@ -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"
+26
View File
@@ -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"
+56
View File
@@ -134,3 +134,59 @@ def test_normalize_report_body_adds_server_and_collapses_blanks():
user_lines = [ln for ln in body.split("\n") if ln.strip().startswith("👤")]
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",
+54
View File
@@ -0,0 +1,54 @@
"""Tests for GET /api/v1/events filters."""
import uuid
VALID_RDG = {
"schema_version": "1.0",
"occurred_at": "2026-05-27T10:00:00+03:00",
"source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"},
"host": {"hostname": "gw-filter-test", "os_family": "windows"},
"category": "auth",
"severity": "info",
"title": "RDG",
"summary": "pytest",
}
def _ingest(client, auth_headers, *, event_type: str) -> str:
event_id = str(uuid.uuid4())
payload = {**VALID_RDG, "event_id": event_id, "type": event_type}
assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201
return event_id
def test_events_type_filter_prefix(client, auth_headers, jwt_headers):
success_id = _ingest(client, auth_headers, event_type="rdg.connection.success")
disconnected_id = _ingest(client, auth_headers, event_type="rdg.connection.disconnected")
other_id = _ingest(client, auth_headers, event_type="rdp.login.success")
listed = client.get(
"/api/v1/events",
headers=jwt_headers,
params={"type": "rdg.connection", "hostname": "gw-filter-test", "page_size": 50},
)
assert listed.status_code == 200
body = listed.json()
ids = {row["event_id"] for row in body["items"]}
assert success_id in ids
assert disconnected_id in ids
assert other_id not in ids
def test_events_type_filter_exact_still_works(client, auth_headers, jwt_headers):
success_id = _ingest(client, auth_headers, event_type="rdg.connection.success")
disconnected_id = _ingest(client, auth_headers, event_type="rdg.connection.disconnected")
listed = client.get(
"/api/v1/events",
headers=jwt_headers,
params={"type": "rdg.connection.success", "hostname": "gw-filter-test", "page_size": 50},
)
assert listed.status_code == 200
ids = {row["event_id"] for row in listed.json()["items"]}
assert success_id in ids
assert disconnected_id not in ids
+3 -3
View File
@@ -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"
+141
View File
@@ -0,0 +1,141 @@
"""Manual host add: validation, probe, deploy kick-off."""
from unittest.mock import patch
import pytest
from app.models import Host
from app.services.host_manual_add import (
ManualHostAddError,
prepare_manual_host_add,
validate_linux_target,
validate_windows_target,
)
from app.services.winrm_connect import WinRmTestResult
from tests.test_agent_update import wait_remote_job
def test_validate_windows_rejects_ip():
with pytest.raises(ManualHostAddError, match="не IP"):
validate_windows_target("10.10.36.9")
def test_validate_windows_accepts_hostname():
assert validate_windows_target("WORKSTATION-01") == "WORKSTATION-01"
assert validate_windows_target(r"B26\WORKSTATION-01") == "WORKSTATION-01"
def test_validate_linux_accepts_ip():
assert validate_linux_target("10.10.36.9") == "10.10.36.9"
def test_prepare_manual_host_windows(db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
mock_win.return_value = WinRmTestResult(
ok=True,
message="WinRM OK, hostname=WORKSTATION-01",
target="WORKSTATION-01",
hostname="WORKSTATION-01",
)
host = prepare_manual_host_add(
db_session,
platform="windows",
target="WORKSTATION-01",
)
assert host.hostname == "WORKSTATION-01"
assert host.product == "rdp-login-monitor"
assert host.os_family == "windows"
def test_api_manual_add_windows(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
mock_win.return_value = WinRmTestResult(
ok=True,
message="WinRM OK, hostname=NEW-PC",
target="NEW-PC",
hostname="NEW-PC",
)
with patch("app.services.agent_update.run_winrm_rdp_monitor_update") as mock_deploy:
from app.services.winrm_connect import WinRmCmdResult
mock_deploy.return_value = WinRmCmdResult(
ok=True,
message="deploy ok",
target="NEW-PC",
stdout="2.1.8-SAC",
)
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "windows", "target": "NEW-PC"},
headers=jwt_headers,
)
assert response.status_code == 202
body = response.json()
assert body["status"] == "running"
host_id = body["host_id"]
host = db_session.get(Host, host_id)
assert host is not None
assert host.hostname == "NEW-PC"
job = wait_remote_job(client, host_id, jwt_headers)
assert job["ok"] is True
def test_api_manual_add_rejects_windows_ip(jwt_headers, client):
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "windows", "target": "192.168.1.10"},
headers=jwt_headers,
)
assert response.status_code == 400
assert "не IP" in response.json()["detail"]
def test_api_manual_add_linux(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_ssh_connection") as mock_ssh:
from app.services.ssh_connect import SshCommandResult
mock_ssh.return_value = SshCommandResult(
ok=True,
message="SSH OK, hostname=linux-srv",
target="10.10.36.9",
stdout="linux-srv\n",
)
with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update:
mock_update.return_value = SshCommandResult(
ok=True,
message="updated",
target="10.10.36.9",
agent_version="2.1.5-SAC",
)
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "linux", "target": "10.10.36.9"},
headers=jwt_headers,
)
assert response.status_code == 202
host_id = response.json()["host_id"]
host = db_session.get(Host, host_id)
assert host.hostname == "linux-srv"
assert host.ipv4 == "10.10.36.9"
job = wait_remote_job(client, host_id, jwt_headers, timeout=8.0)
assert job["ok"] is True
@@ -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
+218
View File
@@ -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"
+154
View File
@@ -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,
+46 -1
View File
@@ -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()
+23
View File
@@ -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)
+38
View File
@@ -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",
+136
View File
@@ -0,0 +1,136 @@
"""Tests for RDG event display (access path, title/summary)."""
from datetime import datetime, timezone
import pytest
from app.models import Event, Host
from app.services.event_summary import event_to_summary
from app.services.rdg_display import (
ACCESS_PATH_DIRECT,
ACCESS_PATH_HAPROXY,
build_rdg_display,
classify_rdg_access_path,
event_supports_rdg_client_qwinsta,
)
def _rdg_event(
db_session,
*,
gw: Host,
event_type: str = "rdg.connection.success",
external_ip: str = "10.0.0.5",
internal_ip: str = "192.168.160.3",
win_id: int = 302,
) -> Event:
event = Event(
event_id=f"ev-rdg-{win_id}",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type=event_type,
severity="info",
title="RD Gateway event 302",
summary="",
payload={},
details={
"user": r"B26\papatramp",
"external_ip": external_ip,
"internal_ip": internal_ip,
"event_id_windows": win_id,
},
)
db_session.add(event)
db_session.commit()
db_session.refresh(event)
return event
def test_classify_rdg_access_path_direct(monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50")
from app.config import get_settings
get_settings.cache_clear()
assert classify_rdg_access_path("10.0.0.5") == ACCESS_PATH_DIRECT
def test_classify_rdg_access_path_haproxy(monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50,10.0.0.100")
from app.config import get_settings
get_settings.cache_clear()
assert classify_rdg_access_path("10.0.0.100") == ACCESS_PATH_HAPROXY
def test_build_rdg_display_title_and_path(db_session, monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "")
from app.config import get_settings
get_settings.cache_clear()
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
ws = Host(hostname="WS-PC", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.3")
db_session.add_all([gw, ws])
db_session.commit()
event = _rdg_event(db_session, gw=gw)
display = build_rdg_display(event, db_session)
assert display is not None
assert ACCESS_PATH_DIRECT in display.title
assert "WS-PC" in display.title
assert "192.168.160.3" in display.title
assert display.access_path == ACCESS_PATH_DIRECT
assert display.qwinsta_enabled is True
assert "papatramp" in display.summary
assert "шлюз K6A-DC3" in display.summary
assert "event 302" in display.title
def test_event_to_summary_enriches_rdg(db_session, monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "10.0.0.5")
from app.config import get_settings
get_settings.cache_clear()
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw, external_ip="10.0.0.5")
summary = event_to_summary(event, db_session)
assert summary.rdg_access_path == ACCESS_PATH_HAPROXY
assert summary.rdg_qwinsta_enabled is True
assert "RDS подключение" in summary.title
assert ACCESS_PATH_HAPROXY in summary.title
def test_event_supports_rdg_client_qwinsta_without_flap(db_session):
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw)
assert event_supports_rdg_client_qwinsta(event, db_session) is True
def test_event_supports_rdg_client_qwinsta_not_on_disconnect(db_session):
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw, event_type="rdg.connection.disconnected", win_id=303)
assert event_supports_rdg_client_qwinsta(event, db_session) is False
@pytest.mark.parametrize(
"internal_ip",
["", None],
)
def test_event_supports_rdg_client_qwinsta_requires_internal_ip(db_session, internal_ip):
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw, internal_ip=internal_ip or "")
if internal_ip is None:
event.details = {k: v for k, v in event.details.items() if k != "internal_ip"}
assert event_supports_rdg_client_qwinsta(event, db_session) is False
+136 -3
View File
@@ -15,6 +15,7 @@ from app.services.rdg_session_flap import (
event_has_rdg_flap,
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
+121 -9
View File
@@ -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,)
+229
View File
@@ -0,0 +1,229 @@
"""Tests for direct RDP logoff → workstation rdp.login.success correlation."""
import uuid
from datetime import datetime, timedelta, timezone
from app.services.event_summary import event_to_summary
from app.services.host_sessions import event_session_terminated
from app.services.ingest import ingest_event
from app.services.rdp_session_logoff import (
SESSION_CLOSED_BY_LOGOFF_AT_KEY,
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY,
close_workstation_session_for_rdp_logoff,
find_logoff_after_workstation_login,
resolve_workstation_login_closed_by_logoff,
)
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
base.update(overrides)
return base
def _ingest(db, occurred_at: datetime, **overrides):
payload = _payload(**overrides)
payload["occurred_at"] = occurred_at.isoformat()
event, _ = ingest_event(db, payload)
db.flush()
return event
def test_logoff_marks_workstation_login_closed_on_ingest(db_session):
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0 + timedelta(seconds=1),
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={
"user": user,
"ip_address": "192.168.160.3",
"logon_type": 10,
"event_id_windows": 4634,
},
)
assert login.details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] == logoff.occurred_at.isoformat()
assert login.details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] == logoff.id
assert event_session_terminated(login, db=db_session) is True
assert event_to_summary(login, db_session).session_terminated is True
def test_user_mismatch_does_not_close_login(db_session):
t0 = datetime.now(timezone.utc)
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": r"B26\Alice"},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={"user": r"B26\Bob", "logon_type": 10, "event_id_windows": 4634},
)
db_session.refresh(login)
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
assert event_session_terminated(login, db=db_session) is False
def test_ip_mismatch_does_not_close_login_when_both_ips_present(db_session):
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={"user": user, "ip_address": "192.168.160.99", "logon_type": 10, "event_id_windows": 4634},
)
db_session.refresh(login)
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
assert event_session_terminated(login, db=db_session) is False
def test_runtime_resolve_for_historical_login_without_flag(db_session):
from app.models import Event, Host
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = Host(
hostname="BIV-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.165.39",
)
db_session.add(host)
db_session.commit()
login = Event(
event_id=str(uuid.uuid4()),
host_id=host.id,
occurred_at=t0 + timedelta(seconds=1),
received_at=t0,
category="auth",
type="rdp.login.success",
severity="info",
title="RDP login",
summary="4624",
payload={},
details={"user": "papatramp", "ip_address": "192.168.160.3"},
)
logoff = Event(
event_id=str(uuid.uuid4()),
host_id=host.id,
occurred_at=t0 + timedelta(hours=2),
received_at=t0,
category="auth",
type="rdp.session.logoff",
severity="info",
title="4634",
summary="4634",
payload={},
details={"user": user, "ip_address": "192.168.160.3", "event_id_windows": 4634},
)
db_session.add_all([login, logoff])
db_session.commit()
assert resolve_workstation_login_closed_by_logoff(db_session, login) is True
assert find_logoff_after_workstation_login(db_session, login) is not None
assert event_to_summary(login, db_session).session_terminated is True
def test_sam_domain_user_match_on_logoff(db_session):
t0 = datetime.now(timezone.utc)
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": r"B26\papatramp", "logon_type": 10},
)
_ingest(
db_session,
t0 + timedelta(minutes=30),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4647",
details={"user": "papatramp", "logon_type": 10, "event_id_windows": 4647},
)
db_session.refresh(login)
assert event_session_terminated(login, db=db_session) is True
+52
View File
@@ -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)
+20
View File
@@ -0,0 +1,20 @@
"""Tests for session_duration_sec extract/format."""
from app.services.session_duration import extract_session_duration_sec, format_session_duration
def test_extract_session_duration_sec():
assert extract_session_duration_sec({"session_duration_sec": 18324}) == 18324
assert extract_session_duration_sec({"session_duration_sec": "42"}) == 42
assert extract_session_duration_sec({"session_duration_sec": ""}) is None
assert extract_session_duration_sec({}) is None
assert extract_session_duration_sec(None) is None
assert extract_session_duration_sec({"session_duration_sec": -1}) is None
def test_format_session_duration():
assert format_session_duration(0) == "00:00:00"
assert format_session_duration(18324) == "05:05:24"
assert format_session_duration(2428) == "00:40:28"
assert format_session_duration(86400 + 85) == "1д 00:01:25"
assert format_session_duration(2 * 86400 + 3661) == "2д 01:01:01"
+68 -5
View File
@@ -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"
+17 -6
View File
@@ -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
+30 -2
View File
@@ -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
+13
View File
@@ -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;
+13
View File
@@ -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
View File
@@ -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
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Uvicorn launcher: SAC_UVICORN_WORKERS из sac-api.env (без systemd EnvironmentFile).
set -euo pipefail
APP_ROOT="${SAC_APP_ROOT:-/opt/security-alert-center}"
CONFIG_FILE="${SAC_CONFIG_FILE:-${APP_ROOT}/config/sac-api.env}"
VENV="${APP_ROOT}/backend/.venv"
HOST="127.0.0.1"
PORT="8000"
WORKERS=4
_read_env_int() {
local key="$1" default="$2" line val
[ -f "$CONFIG_FILE" ] || {
printf '%s\n' "$default"
return 0
}
line="$(grep -E "^[[:space:]]*${key}=" "$CONFIG_FILE" 2>/dev/null | tail -1)" || {
printf '%s\n' "$default"
return 0
}
val="${line#*=}"
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
val="${val#\"}"
val="${val%\"}"
val="${val#\'}"
val="${val%\'}"
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
printf '%s\n' "$val"
else
printf '%s\n' "$default"
fi
}
WORKERS="$(_read_env_int SAC_UVICORN_WORKERS 4)"
exec "${VENV}/bin/uvicorn" app.main:app \
--host "$HOST" \
--port "$PORT" \
--workers "$WORKERS" \
--timeout-graceful-shutdown 30
+2 -2
View File
@@ -1,6 +1,6 @@
[Unit]
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
+7 -5
View File
@@ -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 |
+1
View File
@@ -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
View File
@@ -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`
+2 -1
View File
@@ -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",
+50
View File
@@ -0,0 +1,50 @@
# Backlog: ingest и массовое обновление агентов
**Статус:** ToDo (не в текущем релизе).
**Контекст:** массовый SSH-update через SAC (10+ хостов) + `sac-deploy` в одно окно → часть POST в ingest «теряется» с точки зрения агента (`SAC POST HTTP :`), flood в Telegram (fallback + watchdog).
**Связано:** [runbook-ops.md](runbook-ops.md), [agent-integration.md](agent-integration.md), ssh-monitor [security-roadmap.ru.md](https://git.papatramp.ru/PapaTramp/ssh-monitor/src/branch/main/docs/security-roadmap.ru.md).
Увеличение `SAC_DB_POOL_SIZE` / defer daily в **0.5.0** лечит штурм суточных отчётов и пул БД; **не** снимает узкие места ниже при одновременном restart многих агентов.
---
## Операционно (сразу, без кода)
- [ ] **Не совмещать** `sudo /opt/sac-deploy.sh` и массовое «Обновить ssh-monitor (SSH)» по многим хостам — сначала deploy SAC, потом агенты (или наоборот).
- [ ] Обновлять Linux-хосты **пачками по 23**, не все подряд.
- [ ] На зрелых хостах перевести **`UseSAC=exclusive`** (нет дубля в Telegram с агента при fallback); watchdog по-прежнему шлёт в Telegram сам.
- [ ] Перед первым SSH-update с SAC: **`ssh-keyscan`** в `config/ssh_known_hosts` (см. runbook).
- [ ] В `sac-api.env`: только `KEY=value` на строку, комментарии отдельной строкой с `#`.
---
## SAC (backend / deploy)
- [x] **Defer** `notify_lifecycle` и `notify_auth_login` в background (как `report.daily.*``schedule_notify_daily_report`), чтобы ingest не ждал Telegram API — **0.5.5**
- [x] **Uvicorn workers:** 4 по умолчанию или `SAC_UVICORN_WORKERS` в `sac-api.env` / `sac-api-start.sh` — **0.5.5**
- [x] **nginx:** отдельный `location` для `POST /api/v1/events` с увеличенным `proxy_read_timeout` — **0.5.5**
- [ ] Опционально: метрики/лог длительности ingest и очереди при burst.
- [x] UI: предупреждение при массовом update («N хостов — рекомендуется пачками») — **0.5.5**
---
## ssh-monitor (агент)
- [x] При **shutdown** / `SIGTERM` не увеличивать `sac-fail.count` — **2.3.2-SAC**
- [x] После успешного heartbeat сбрасывать fail counter (успешный POST ingest) — уже было
- [x] Watchdog: не слать Telegram при штатном restart во время SAC-update (state file `/var/lib/ssh-monitor/agent-update-in-progress` от updater) — **2.2.1-SAC**
- [x] Sudo bootstrap/update через SAC: не слать Telegram (`ssh_monitor_sudo_is_sac_maintenance`, state-file раньше) — **2.2.3-SAC**
- [x] SAC: suppress `privilege.sudo.command` при `agent_update_state=running` / maintenance command — **0.5.3**
- [x] Документировать рекомендуемый `SAC_TIMEOUT_SEC` при тяжёлом ingest — **2.3.2-SAC** (`docs/sac-ingest.ru.md`)
---
## Принято / сделано
- [x] Pool БД 15+25, defer daily push — SAC **0.5.0**
- [x] `sac-deploy.sh` без `source` конфига, preflight pydantic — SAC `fa1bd41`
- [x] Watchdog: строка `🖥️ Сервер:` в Telegram — ssh-monitor **2.1.9-SAC**
---
*После реализации пунктов SAC — bump `APP_VERSION` и runbook.*
+1 -1
View File
@@ -24,7 +24,7 @@ Docker удобен для локальной отладки или изолир
```bash
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
```
+1 -1
View File
@@ -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
```
---
+1 -1
View File
@@ -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
```
+1 -1
View File
@@ -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