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
52 changed files with 1002 additions and 117 deletions
+6 -6
View File
@@ -1,4 +1,4 @@
# Security Alert Center (SAC)
# Security Alert Center (SAC)
Центральная платформа: приём событий от агентов, корреляция, UI, оповещения, управление хостами.
@@ -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.5.14` · **Деплой:** `sudo /opt/sac-deploy.sh`
**Версия:** `0.5.16` · **Деплой:** `sudo /opt/sac-deploy.sh`
## Возможности
@@ -40,7 +40,7 @@
## Быстрый старт
```bash
git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
# deploy/env.native.example → config/sac-api.env (JWT_SECRET, CORS_ORIGINS, SAC_SECURITY_ENFORCE)
cd /opt/security-alert-center/backend && python3 -m venv .venv
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
+6 -6
View File
@@ -1,4 +1,4 @@
# Security Alert Center (SAC)
# Security Alert Center (SAC)
Self-hosted hub for security events from Linux and Windows agents: ingest, correlation, UI, notifications, host management.
@@ -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.5.14` · **Deploy:** `sudo /opt/sac-deploy.sh`
**Version:** `0.5.16` · **Deploy:** `sudo /opt/sac-deploy.sh`
## Features
@@ -39,7 +39,7 @@ 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
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,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")
+6 -4
View File
@@ -28,12 +28,12 @@ from app.services.host_sessions import (
mark_event_session_terminated,
terminate_session_for_event,
)
from app.services.linux_admin_settings import get_effective_linux_admin_config
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
from app.services.ssh_connect import (
HostNotLinuxError as SshHostNotLinuxError,
HostTargetMissingError as SshHostTargetMissingError,
)
from app.services.win_admin_settings import get_effective_win_admin_config
from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
from app.services.ingest import ingest_event
from app.services.event_summary import event_to_summary
@@ -284,8 +284,10 @@ def post_event_terminate_session(
if event_session_terminated(event, db=db):
raise HTTPException(status_code=409, detail="Session already terminated for this event")
linux_cfg = get_effective_linux_admin_config(db)
win_cfg = get_effective_win_admin_config(db)
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:
+109 -16
View File
@@ -46,8 +46,12 @@ from app.services.host_sessions import (
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,
@@ -299,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
@@ -469,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)
@@ -532,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)
@@ -556,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:
@@ -686,9 +716,12 @@ def list_host_sessions(
from app.services.winrm_connect import is_windows_host
if is_linux_host(host):
cfg = get_effective_linux_admin_config(db)
cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
try:
sessions, result = list_linux_sessions(host, cfg)
except SshHostNotLinuxError as exc:
@@ -703,9 +736,12 @@ def list_host_sessions(
)
if is_windows_host(host):
cfg = get_effective_win_admin_config(db)
cfg = get_effective_win_admin_for_host(db, host)
if not cfg.configured:
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:
sessions, result = list_windows_sessions(host, cfg)
except HostNotWindowsError as exc:
@@ -743,9 +779,12 @@ def terminate_host_session(
raise HTTPException(status_code=422, detail="session_id is required")
if is_linux_host(host):
cfg = get_effective_linux_admin_config(db)
cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured:
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
raise HTTPException(
status_code=400,
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
)
try:
result = terminate_linux_session(host, cfg, sid)
except SshHostNotLinuxError as exc:
@@ -761,9 +800,12 @@ def terminate_host_session(
)
if is_windows_host(host):
cfg = get_effective_win_admin_config(db)
cfg = get_effective_win_admin_for_host(db, host)
if not cfg.configured:
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:
result = terminate_windows_session(host, cfg, sid)
except HostNotWindowsError as exc:
@@ -803,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,
+3 -3
View File
@@ -1,4 +1,4 @@
import os
import os
from functools import lru_cache
from pathlib import Path
@@ -138,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
+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))
+1
View File
@@ -78,6 +78,7 @@ 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
+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,
+4
View File
@@ -6,6 +6,7 @@ from app.services.event_actor_user import extract_event_actor_user
from app.services.host_sessions import event_session_terminated
from app.services.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:
@@ -44,6 +45,9 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
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,
@@ -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)
+4 -4
View File
@@ -14,7 +14,7 @@ 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,
@@ -97,7 +97,7 @@ def _completion_message(result: SshCommandResult) -> str:
def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str:
cfg = get_effective_linux_admin_config(db)
cfg = get_effective_linux_admin_for_host(db, host)
if not cfg.configured:
return ""
try:
@@ -191,7 +191,7 @@ def _poll_ssh_update_log_loop(
payload = dict(row.remote_action or {})
if (payload.get("status") or "").strip().lower() != "running":
continue
cfg = get_effective_linux_admin_config(session)
cfg = get_effective_linux_admin_for_host(session, row)
if not cfg.configured:
continue
try:
@@ -322,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,
+9
View File
@@ -128,6 +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
+11 -6
View File
@@ -12,7 +12,7 @@ from app.models import AgentCommand, Event, Host
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
from app.services.rdg_display import event_supports_rdg_client_qwinsta
from app.services.rdg_session_flap import event_internal_ip
from app.services.win_admin_settings import get_effective_win_admin_config
from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.winrm_connect import (
WinRmCmdResult,
run_winrm_logoff,
@@ -21,12 +21,17 @@ from app.services.winrm_connect import (
)
def _require_win_admin(db: Session):
cfg = get_effective_win_admin_config(db)
def _require_win_admin(db: Session, host: Host | None = None):
if host is not None:
cfg = get_effective_win_admin_for_host(db, host)
else:
from app.services.win_admin_settings import get_effective_win_admin_config
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise HTTPException(
status_code=503,
detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
detail="Windows admin is not configured (host override or Settings → Windows)",
)
return cfg
@@ -93,8 +98,8 @@ def _persist_command(
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
_require_rdg_client_qwinsta(db, event)
cfg = _require_win_admin(db)
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")
@@ -136,8 +141,8 @@ def execute_logoff_via_winrm(
requested_by: str,
) -> AgentCommand:
_require_rdg_client_qwinsta(db, event)
cfg = _require_win_admin(db)
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")
+129 -2
View File
@@ -2,6 +2,8 @@
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
@@ -15,6 +17,7 @@ from app.services.host_sessions import (
from app.services.rdg_client_host import find_windows_host_by_ipv4
from app.services.rdg_session_flap import (
RDG_END_TYPES,
RDG_SUCCESS_TYPE,
_event_user,
event_internal_ip,
find_rdg_success_before_end,
@@ -22,10 +25,22 @@ from app.services.rdg_session_flap import (
SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at"
SESSION_CLOSED_BY_RDG_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:
@@ -41,6 +56,115 @@ def users_match_rdg(login_user: str, rdg_user: str) -> bool:
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)
@@ -98,8 +222,11 @@ def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Eve
for login in candidates:
if _login_already_closed(login):
continue
login_user = _event_login_user(login)
if not users_match_rdg(login_user, rdg_user):
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
@@ -35,7 +35,7 @@ from app.services.rdg_workstation_session import (
users_match_rdg,
)
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings
from app.services.win_admin_settings import get_effective_win_admin_config
from app.services.win_admin_settings import get_effective_win_admin_for_host
logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
@@ -143,7 +143,7 @@ def _disconnect_on_workstation(
user: str,
login_event: Event | None,
) -> AutoDisconnectResult:
win_cfg = get_effective_win_admin_config(db)
win_cfg = get_effective_win_admin_for_host(db, workstation)
sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
if qwinsta is None or not qwinsta.ok:
message = qwinsta.message if qwinsta else "qwinsta failed"
@@ -291,14 +291,6 @@ def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDi
if not get_effective_rdp_flap_settings(db).auto_disconnect:
return None
win_cfg = get_effective_win_admin_config(db)
if not win_cfg.configured:
logger.warning(
"auto rdp flap disconnect skipped: win admin not configured (event_id=%s)",
event.event_id,
)
return None
result = _auto_disconnect_rdg_flap(db, event)
if result is not None:
if result.ok:
+37
View File
@@ -0,0 +1,37 @@
"""Format and extract RDP/RDG session_duration_sec for UI."""
from __future__ import annotations
from typing import Any
def extract_session_duration_sec(details: dict[str, Any] | None) -> int | None:
if not isinstance(details, dict):
return None
raw = details.get("session_duration_sec")
if raw is None or raw == "":
return None
try:
value = int(raw)
except (TypeError, ValueError):
return None
if value < 0:
return None
return value
def format_session_duration(seconds: int) -> str:
"""
Human-readable session length for lists:
- under 24h → HH:MM:SS (05:05:24)
- 24h+ → Nд HH:MM:SS (1д 00:01:25)
"""
if seconds < 0:
return ""
days, rem = divmod(int(seconds), 86_400)
hours, rem = divmod(rem, 3600)
minutes, secs = divmod(rem, 60)
clock = f"{hours:02d}:{minutes:02d}:{secs:02d}"
if days:
return f"{days}д {clock}"
return clock
+2 -2
View File
@@ -1,4 +1,4 @@
"""SSH connectivity and remote commands for Linux hosts."""
"""SSH connectivity and remote commands for Linux hosts."""
from __future__ import annotations
@@ -14,7 +14,7 @@ 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 "
+5 -3
View File
@@ -564,9 +564,11 @@ def _format_rdg_html(event: Event) -> str:
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
+2 -2
View File
@@ -1,4 +1,4 @@
"""WinRM connectivity test for Windows hosts using domain admin credentials."""
"""WinRM connectivity test for Windows hosts using domain admin credentials."""
from __future__ import annotations
@@ -487,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:
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.5.14"
APP_VERSION = "0.5.17"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+1 -1
View File
@@ -14,7 +14,7 @@ from app.services.agent_version import reference_agent_versions_by_product
def test_normalize_git_repo_url():
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"
@@ -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
@@ -266,3 +266,113 @@ def test_ip_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts):
assert close_workstation_session_for_rdg_end(db_session, end) is None
assert 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
+20
View File
@@ -0,0 +1,20 @@
"""Tests for session_duration_sec extract/format."""
from app.services.session_duration import extract_session_duration_sec, format_session_duration
def test_extract_session_duration_sec():
assert extract_session_duration_sec({"session_duration_sec": 18324}) == 18324
assert extract_session_duration_sec({"session_duration_sec": "42"}) == 42
assert extract_session_duration_sec({"session_duration_sec": ""}) is None
assert extract_session_duration_sec({}) is None
assert extract_session_duration_sec(None) is None
assert extract_session_duration_sec({"session_duration_sec": -1}) is None
def test_format_session_duration():
assert format_session_duration(0) == "00:00:00"
assert format_session_duration(18324) == "05:05:24"
assert format_session_duration(2428) == "00:40:28"
assert format_session_duration(86400 + 85) == "1д 00:01:25"
assert format_session_duration(2 * 86400 + 3661) == "2д 01:01:01"
+2 -2
View File
@@ -1,4 +1,4 @@
"""SSH connect service tests (paramiko mocked via sys.modules)."""
"""SSH connect service tests (paramiko mocked via sys.modules)."""
import sys
from unittest.mock import MagicMock
@@ -323,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"
+1 -1
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
+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 -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",
+1 -1
View File
@@ -2,7 +2,7 @@
**Статус:** 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.kalinamall.ru/PapaTramp/ssh-monitor/src/branch/main/docs/security-roadmap.ru.md).
**Связано:** [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 многих агентов.
+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) |
+3 -3
View File
@@ -66,7 +66,7 @@
**Цель:** нативный Android-клиент и push по тем же правилам оповещений, что Telegram/email/webhook.
Репозиторий клиента: [seaca](https://git.kalinamall.ru/PapaTramp/seaca) (Kotlin, Jetpack Compose, FCM).
Репозиторий клиента: [seaca](https://git.papatramp.ru/PapaTramp/seaca) (Kotlin, Jetpack Compose, FCM).
**Сервер SAC:**
@@ -77,7 +77,7 @@
**Не в мобильном клиенте:** пользователи SAC, каналы Telegram/SMTP/webhook, админ-настройки.
Подробный план клиента — [seaca/docs/ROADMAP.md](https://git.kalinamall.ru/PapaTramp/seaca/src/branch/main/docs/ROADMAP.md).
Подробный план клиента — [seaca/docs/ROADMAP.md](https://git.papatramp.ru/PapaTramp/seaca/src/branch/main/docs/ROADMAP.md).
---
@@ -112,7 +112,7 @@
| v0.1 | — (не требуется) | — |
| v0.2 | TBD (тег с UseSAC) | TBD |
| v0.3+ | совместимость schema 1.0 | совместимость schema 1.0 |
| v0.6 | — | — (мобильный клиент [seaca](https://git.kalinamall.ru/PapaTramp/seaca), не агент) |
| v0.6 | — | — (мобильный клиент [seaca](https://git.papatramp.ru/PapaTramp/seaca), не агент) |
При breaking change схемы — `schema_version: 1.1` с поддержкой 1.0 на ingest.
+2 -2
View File
@@ -1,6 +1,6 @@
# Seaca — мобильный клиент SAC
Репозиторий приложения: [seaca](https://git.kalinamall.ru/PapaTramp/seaca).
Репозиторий приложения: [seaca](https://git.papatramp.ru/PapaTramp/seaca).
Требуется SAC **≥ 0.9.0** с применённой миграцией `014`.
---
@@ -84,7 +84,7 @@ SAC_FCM_SERVICE_ACCOUNT_JSON=/etc/security-alert-center/fcm-service-account.json
| `sac.kalinamall.ru` | Web UI | yes (`git-sac-allowed`) |
| `sac-api.kalinamall.ru` | Seaca, `/api/v1/mobile/*` | no (enroll + JWT) |
Both resolve to the same SAC server (**192.168.160.145**). nginx must accept both names in `server_name`. See [reverse-proxy/docs/sac-access.md](https://git.kalinamall.ru/PapaTramp/reverse-proxy/src/branch/main/docs/sac-access.md).
Both resolve to the same SAC server (**192.168.160.145**). nginx must accept both names in `server_name`. See [reverse-proxy/docs/sac-access.md](https://git.papatramp.ru/PapaTramp/reverse-proxy/src/branch/main/docs/sac-access.md).
---
+6 -6
View File
@@ -8,9 +8,9 @@
| Имя в workspace | Путь (пример Windows) | Remote |
|-----------------|----------------------|--------|
| `ssh-monitor` | `D:\Soft\Git\ssh-monitor` | git.kalinamall.ru/PapaTramp/ssh-monitor |
| `rdp-monitor` | `D:\Soft\Git\RDP-login-monitor` | git.kalinamall.ru/PapaTramp/RDP-login-monitor |
| `security-alert-center` | `D:\Soft\Git\security-alert-center` | git.kalinamall.ru/PapaTramp/security-alert-center |
| `ssh-monitor` | `D:\Soft\Git\ssh-monitor` | git.papatramp.ru/PapaTramp/ssh-monitor |
| `rdp-monitor` | `D:\Soft\Git\RDP-login-monitor` | git.papatramp.ru/PapaTramp/RDP-login-monitor |
| `security-alert-center` | `D:\Soft\Git\security-alert-center` | git.papatramp.ru/PapaTramp/security-alert-center |
Пути подставьте свои.
@@ -65,9 +65,9 @@ git push kalinamall main
| Проект | URL |
|--------|-----|
| security-alert-center | `https://git.kalinamall.ru/PapaTramp/security-alert-center.git` |
| ssh-monitor | `https://git.kalinamall.ru/PapaTramp/ssh-monitor.git` |
| RDP-login-monitor | `https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git` |
| security-alert-center | `https://git.papatramp.ru/PapaTramp/security-alert-center.git` |
| ssh-monitor | `https://git.papatramp.ru/PapaTramp/ssh-monitor.git` |
| RDP-login-monitor | `https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git` |
---
+26
View File
@@ -192,6 +192,7 @@ export interface EventSummary {
title: string;
summary: string;
actor_user?: string | null;
session_duration_sec?: number | null;
rdg_flap?: boolean;
rdg_flap_pair_event_id?: number | null;
rdg_flap_qwinsta_event_id?: number | null;
@@ -531,6 +532,31 @@ export function updateLinuxAdminSettings(payload: {
});
}
export interface HostMgmtAccess {
host_id: number;
has_override: boolean;
user: string | null;
password_set: boolean;
password_hint: string | null;
effective_source: string;
effective_configured: boolean;
effective_user: string | null;
}
export function fetchHostAccess(hostId: number): Promise<HostMgmtAccess> {
return apiFetch<HostMgmtAccess>(`/api/v1/hosts/${hostId}/access`);
}
export function updateHostAccess(
hostId: number,
payload: { user?: string | null; password?: string | null; clear?: boolean },
): Promise<HostMgmtAccess> {
return apiFetch<HostMgmtAccess>(`/api/v1/hosts/${hostId}/access`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
export interface HostSshActionResult {
ok: boolean;
message: string;
+17
View File
@@ -0,0 +1,17 @@
/** Human-readable session_duration_sec for event tables. */
export function formatSessionDuration(seconds: number | null | undefined): string {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) {
return "—";
}
const total = Math.floor(seconds);
const days = Math.floor(total / 86_400);
const hours = Math.floor((total % 86_400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const clock = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
if (days > 0) {
return `${days}д ${clock}`;
}
return clock;
}
+1 -1
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.5.14";
export const APP_VERSION = "0.5.17";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+5
View File
@@ -238,6 +238,8 @@
<th>Пользователь</th>
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
<th>Версия агента</th>
<th>Severity</th>
@@ -276,6 +278,8 @@
<td>{{ e.actor_user || "—" }}</td>
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
<td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
@@ -354,6 +358,7 @@ import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
import { formatServerName } from "../utils/hostDisplay";
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
import { formatSessionDuration } from "../utils/sessionDuration";
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
+3
View File
@@ -34,6 +34,7 @@
<th>Хост</th>
<th>Имя сервера</th>
<th>Пользователь</th>
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
<th>Версия агента</th>
<th>Severity</th>
<th>Type</th>
@@ -50,6 +51,7 @@
<td>{{ e.hostname }}</td>
<td>{{ formatServerName(e.display_name) }}</td>
<td>{{ e.actor_user || "—" }}</td>
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
<td>{{ e.product_version || "—" }}</td>
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
<td><code>{{ e.type }}</code></td>
@@ -120,6 +122,7 @@ import {
type EventsListState,
} from "../utils/eventsListQuery";
import { formatServerName } from "../utils/hostDisplay";
import { formatSessionDuration } from "../utils/sessionDuration";
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
const route = useRoute();
+180
View File
@@ -69,6 +69,52 @@
<dd>{{ host.event_count ?? 0 }}</dd>
</div>
</dl>
<section v-if="showAgentConfig" class="host-mgmt-access">
<h3>{{ isWindowsHost ? "WinRM / доступ к хосту" : "SSH / доступ к хосту" }}</h3>
<p class="muted">
Переопределение для этого ПК. Если пусто используются глобальные учётные данные из
<RouterLink to="/settings">Настройки</RouterLink>
({{ isWindowsHost ? "Windows admin" : "Linux admin" }}).
Сейчас effective:
<strong>{{ accessEffectiveLabel }}</strong>
</p>
<form class="agent-config-form" @submit.prevent="saveHostAccess">
<label class="config-field">
Логин
<input
v-model="accessForm.user"
type="text"
:placeholder="isWindowsHost ? 'COMPUTER\\user или .\\Administrator' : 'root'"
autocomplete="off"
/>
</label>
<label class="config-field">
Пароль
<input
v-model="accessForm.password"
type="password"
:placeholder="accessForm.passwordSet ? '•••• (оставьте пустым, чтобы не менять)' : ''"
autocomplete="new-password"
/>
</label>
<div class="host-actions-row">
<button type="submit" :disabled="savingAccess">
{{ savingAccess ? "Сохранение…" : "Сохранить доступ" }}
</button>
<button
type="button"
class="secondary"
:disabled="savingAccess || !accessForm.hasOverride"
@click="clearHostAccess"
>
Сбросить (глобальные)
</button>
</div>
<p v-if="accessMessage" :class="accessOk ? 'success' : 'error'">{{ accessMessage }}</p>
</form>
</section>
<div v-if="isWindowsHost" class="host-actions">
<button
type="button"
@@ -296,6 +342,8 @@ import {
import {
apiFetch,
fetchHost,
fetchHostAccess,
updateHostAccess,
fetchRecentEvents,
testHostWinRm,
testHostSsh,
@@ -303,6 +351,7 @@ import {
patchHostAgentConfig,
type EventListResponse,
type HostDetail,
type HostMgmtAccess,
} from "../api";
import { emitHostPatch } from "../utils/hostPatchBus";
import HostSessionsPanel from "../components/HostSessionsPanel.vue";
@@ -354,6 +403,32 @@ const configForm = reactive({
serverDisplayName: "",
getInventory: true,
});
const savingAccess = ref(false);
const accessMessage = ref("");
const accessOk = ref(false);
const accessForm = reactive({
user: "",
password: "",
passwordSet: false,
hasOverride: false,
effectiveSource: "",
effectiveUser: "",
effectiveConfigured: false,
});
const accessEffectiveLabel = computed(() => {
if (!accessForm.effectiveConfigured) {
return "не настроено";
}
const src =
accessForm.effectiveSource === "host"
? "override хоста"
: accessForm.effectiveSource === "db"
? "глобальные (Settings)"
: accessForm.effectiveSource === "env"
? "env"
: accessForm.effectiveSource;
return `${accessForm.effectiveUser || "—"} · ${src}`;
});
let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null;
let sshProbeSeq = 0;
@@ -678,6 +753,67 @@ async function saveAgentConfig() {
}
}
async function applyAccessView(view: HostMgmtAccess) {
accessForm.user = view.user || "";
accessForm.password = "";
accessForm.passwordSet = view.password_set;
accessForm.hasOverride = view.has_override;
accessForm.effectiveSource = view.effective_source;
accessForm.effectiveUser = view.effective_user || "";
accessForm.effectiveConfigured = view.effective_configured;
}
async function loadHostAccess() {
accessMessage.value = "";
try {
const view = await fetchHostAccess(hostId.value);
applyAccessView(view);
} catch (e) {
accessOk.value = false;
accessMessage.value = e instanceof Error ? e.message : "Не удалось загрузить доступ хоста";
}
}
async function saveHostAccess() {
savingAccess.value = true;
accessMessage.value = "";
try {
const payload: { user?: string | null; password?: string | null } = {
user: accessForm.user,
};
if (accessForm.password.trim()) {
payload.password = accessForm.password;
}
const view = await updateHostAccess(hostId.value, payload);
applyAccessView(view);
accessOk.value = true;
accessMessage.value = view.has_override
? "Сохранено (override хоста)"
: "Сохранено (логин очищен — глобальные настройки)";
} catch (e) {
accessOk.value = false;
accessMessage.value = e instanceof Error ? e.message : "Ошибка сохранения доступа";
} finally {
savingAccess.value = false;
}
}
async function clearHostAccess() {
savingAccess.value = true;
accessMessage.value = "";
try {
const view = await updateHostAccess(hostId.value, { clear: true });
applyAccessView(view);
accessOk.value = true;
accessMessage.value = "Override сброшен — используются глобальные настройки";
} catch (e) {
accessOk.value = false;
accessMessage.value = e instanceof Error ? e.message : "Ошибка сброса доступа";
} finally {
savingAccess.value = false;
}
}
async function loadHost() {
loading.value = true;
error.value = "";
@@ -692,6 +828,7 @@ async function loadHost() {
try {
const detail = await fetchHost(hostId.value);
applyHostDetail(detail);
void loadHostAccess();
if (isLinuxHostDetail(detail)) {
void probeSshOnOpen();
}
@@ -848,6 +985,49 @@ watch(
color: #9aa4b2;
}
.host-mgmt-access {
margin: 1rem 0 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid #2a3340;
border-radius: 8px;
background: #141a22;
}
.host-mgmt-access h3 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.host-mgmt-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 0.75rem;
max-width: 28rem;
}
.host-mgmt-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
}
.host-mgmt-field input {
padding: 0.4rem 0.55rem;
border-radius: 6px;
border: 1px solid #3a4554;
background: #0f1419;
color: #e8eef5;
}
.host-mgmt-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.host-actions {
margin-top: 1rem;
display: flex;
+7 -5
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="settings-page">
<h1>Настройки</h1>
<p class="settings-intro">
@@ -126,8 +126,9 @@
<section class="card settings-card settings-policy">
<h2>Windows (qwinsta / logoff)</h2>
<p class="settings-hint settings-hint-top">
Доменный admin для удалённых команд на Windows-хостах (qwinsta, logoff через агент).
Глобальный (доменный) admin для удалённых команд на Windows-хостах (qwinsta, logoff, WinRM-update).
Формат логина: <code>ДОМЕН\пользователь</code> (например <code>B26\papatramp</code>).
Для отдельного ПК (home/workgroup) задайте override в карточке <strong>Хосты доступ к хосту</strong>.
Пока запись не создана в UI используются <code>SAC_WIN_ADMIN_*</code> из <code>sac-api.env</code>.
</p>
<form class="settings-form" @submit.prevent="saveWinAdminSettings">
@@ -186,8 +187,9 @@
<section class="card settings-card settings-policy">
<h2>Linux (SSH / обновление ssh-monitor)</h2>
<p class="settings-hint settings-hint-top">
SSH-учётка для удалённого запуска <code>/opt/scripts/update_ssh_monitor.sh</code> с карточки Linux-хоста.
Глобальная SSH-учётка для удалённого запуска <code>/opt/scripts/update_ssh_monitor.sh</code> с карточки Linux-хоста.
Рекомендуется <code>root</code> или пользователь с <code>sudo NOPASSWD</code>.
Для отдельного сервера задайте override в карточке <strong>Хосты доступ к хосту</strong>.
Пока запись не создана в UI используются <code>SAC_LINUX_ADMIN_*</code> из <code>sac-api.env</code>.
</p>
<form class="settings-form" @submit.prevent="saveLinuxAdminSettings">
@@ -251,7 +253,7 @@
<input
v-model="agentUpdateForm.rdp_git_repo_url"
type="text"
placeholder="https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
placeholder="https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
/>
</label>
<label class="settings-field">
@@ -259,7 +261,7 @@
<input
v-model="agentUpdateForm.ssh_git_repo_url"
type="text"
placeholder="https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
placeholder="https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
/>
</label>
<label class="settings-field">
+1 -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",
Executable → Regular
View File
+2 -2
View File
@@ -7,8 +7,8 @@ REMOTE_CMD = (
"set -e\n"
"TMP=$(mktemp -d)\n"
'trap "rm -rf \\"$TMP\\"" EXIT\n'
"git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git \"$TMP/rdp\"\n"
"git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/ssh-monitor.git \"$TMP/ssh\"\n"
"git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git \"$TMP/rdp\"\n"
"git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/ssh-monitor.git \"$TMP/ssh\"\n"
"echo RDP_version_txt:\n"
"cat \"$TMP/rdp/version.txt\" 2>/dev/null || echo missing\n"
"echo RDP_script:\n"
+2 -2
View File
@@ -2,8 +2,8 @@
set -e
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git "$TMP/rdp"
git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/ssh-monitor.git "$TMP/ssh"
git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git "$TMP/rdp"
git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/ssh-monitor.git "$TMP/ssh"
echo RDP_version_txt:
cat "$TMP/rdp/version.txt" 2>/dev/null || echo missing
echo RDP_script: