Compare commits
223 Commits
main
..
9bfc5e23f7
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bfc5e23f7 | |||
| 9e43de6fc1 | |||
| 80320ae698 | |||
| 939fe122c5 | |||
| c72e510fb0 | |||
| 6c43b8637e | |||
| 3765d4d476 | |||
| 10ae670962 | |||
| daeb7e965d | |||
| 606bf53019 | |||
| fd1be5e7e4 | |||
| 5a7909bb55 | |||
| 86fd400f0b | |||
| dd2ae51b43 | |||
| 719d5c5719 | |||
| bda02f67eb | |||
| 9053ef0588 | |||
| 0601aafd2d | |||
| 8b3e43f30d | |||
| 6330f31162 | |||
| b47cbf03c5 | |||
| 407ef37004 | |||
| 64b942447b | |||
| 8b52c5fe3c | |||
| fbd213f323 | |||
| 1b96a3b2c7 | |||
| 24ca87a47a | |||
| 4eb41608ea | |||
| 6e3f0ede4d | |||
| fe818e9946 | |||
| 5e26834e16 | |||
| 54c1d96933 | |||
| a51a585b14 | |||
| fc66b57e78 | |||
| ad2425b0ff | |||
| d848d94604 | |||
| ca0255e13b | |||
| d96d9d6333 | |||
| 7397ec30cd | |||
| 75eec35365 | |||
| b3d28cd5d8 | |||
| 5bd1333928 | |||
| bf7c83c408 | |||
| 87ac1e09b0 | |||
| 8adaf3bfd3 | |||
| 8a451a0c49 | |||
| 5635be1322 | |||
| d7bbcc5337 | |||
| f3a8952947 | |||
| 4d9a9e7e2b | |||
| 9484160045 | |||
| 6acbfe22de | |||
| ca90f5c296 | |||
| 191fb4e2cf | |||
| 8c1a8fddb1 | |||
| 05408e17c3 | |||
| d3517a5706 | |||
| 2c2f78eba9 | |||
| ec5ec62240 | |||
| 115289ef35 | |||
| fc9d630263 | |||
| 01d72d0f68 | |||
| a35e50ccd1 | |||
| 34a1670ea9 | |||
| c21043d845 | |||
| 07b97bd111 | |||
| f411d8f070 | |||
| d820d941c1 | |||
| cd2f27792d | |||
| 75f4c475df | |||
| 2eb06acb5b | |||
| 6fea8262fb | |||
| 2e839e0b16 | |||
| cc8f50de89 | |||
| 279ea925a8 | |||
| c8e3eef9d7 | |||
| 2fe492b06b | |||
| 154ba3efc2 | |||
| df4c93d243 | |||
| db95be39f3 | |||
| 2aec488226 | |||
| b328d32f97 | |||
| 308e075f35 | |||
| 63cc2954af | |||
| 45f45145f0 | |||
| 55f047cd35 | |||
| 8a97bbfb43 | |||
| 2069e57f14 | |||
| 6b288bfa49 | |||
| 6bff073108 | |||
| 06c6748274 | |||
| eac1e0b59e | |||
| 36ccfc9471 | |||
| 5b2aeb0183 | |||
| 3d36faa49d | |||
| e022f2f213 | |||
| f0bd31c544 | |||
| 87549731c8 | |||
| 7df718eed3 | |||
| befaf86bf4 | |||
| fed21c536b | |||
| 3f671b9899 | |||
| 3f7095ed03 | |||
| 37bffe0e3f | |||
| 0a414a0904 | |||
| e38bd8a52a | |||
| f9baf96d88 | |||
| 5e9f0f8110 | |||
| e2b5fb7604 | |||
| 563b836acc | |||
| 86c602cf07 | |||
| 77e167b6e3 | |||
| f9d56621f6 | |||
| e65daa8c3b | |||
| aafb80fa31 | |||
| 2a0eb6e90c | |||
| 459b2babd1 | |||
| 95c5b9f25a | |||
| 02200f1a80 | |||
| 502971367e | |||
| d9893497ce | |||
| adfc69c656 | |||
| 7699d8690a | |||
| 54337a5917 | |||
| 5d3cda2ab6 | |||
| 57793d24ba | |||
| d324e41eba | |||
| 1580982d88 | |||
| d8ba864e48 | |||
| 7ab497b300 | |||
| 2d39943020 | |||
| 79185d9186 | |||
| 47d08b54d2 | |||
| b565a259ea | |||
| 0d4a50cd35 | |||
| 3053058cdf | |||
| cf5a6f995c | |||
| 141fc44099 | |||
| d3a337992c | |||
| 06a8ed8614 | |||
| de29270a25 | |||
| 8de5d14cfb | |||
| 56c469ddbd | |||
| bc77f82307 | |||
| 7e13be3c38 | |||
| fbef1e2f4b | |||
| 4baa2b426f | |||
| 9dacbd7944 | |||
| 366a9c83f7 | |||
| 3d4d1f3c76 | |||
| a45b641b8c | |||
| 799f8668e6 | |||
| 2501ecc4fe | |||
| eba2f0d15c | |||
| 7ebbb9a9d1 | |||
| 11195ae64f | |||
| 2670c8cb46 | |||
| 0baeacf808 | |||
| 42f31b3bb5 | |||
| 415d863b3b | |||
| 4167687dec | |||
| 1a70980920 | |||
| ebb450be92 | |||
| 9b177c145b | |||
| 20fa7e2c27 | |||
| f7b5fff04e | |||
| 79a78dc7c9 | |||
| e0ba384705 | |||
| 117390cbca | |||
| a42a63b8fa | |||
| daec333dbe | |||
| ac3c20b7db | |||
| dce5b88b2f | |||
| 81cf9c46b3 | |||
| d4bf6e536c | |||
| 108e1756c4 | |||
| 9ed0670863 | |||
| fcf3da223c | |||
| f322a46f98 | |||
| a218ff2581 | |||
| 98055dfdf8 | |||
| c8f357007f | |||
| 249894d8e1 | |||
| 64b5ef297a | |||
| 83f48dce97 | |||
| a4a224b284 | |||
| 8eb40cf75d | |||
| 4d603637e5 | |||
| e0b066f5e5 | |||
| 25a485fabb | |||
| 2b76ae8497 | |||
| 5cf76f492c | |||
| d1b89c75dd | |||
| 721de21d5c | |||
| 8995a34037 | |||
| 61c9f9b673 | |||
| 04301ab359 | |||
| 9ba3c4b63b | |||
| b404747f8d | |||
| c657a65970 | |||
| ea221db3c7 | |||
| eeba3a704d | |||
| 5fce00faae | |||
| b19cdf2b47 | |||
| 947fc4ab98 | |||
| 4265d58c4b | |||
| a4bbc14448 | |||
| bdfc7016e6 | |||
| d8a8329397 | |||
| a9bea3d276 | |||
| 0c5d2537db | |||
| 21578b27c9 | |||
| 2c12f161a1 | |||
| 934a5a0c9c | |||
| 539126b75e | |||
| 38c137cbd3 | |||
| 6d33669ac2 | |||
| 69547d83bc | |||
| 9203af90d5 | |||
| 69a232d08a | |||
| bf1c1c9fe9 | |||
| 2f82c7c658 | |||
| 4f3adc9429 |
@@ -1,2 +0,0 @@
|
|||||||
# Shell scripts must use LF — CRLF in shebang breaks systemd (status=203/EXEC).
|
|
||||||
*.sh text eol=lf
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Security Alert Center (SAC)
|
# Security Alert Center (SAC)
|
||||||
|
|
||||||
Центральная платформа: приём событий от агентов, корреляция, UI, оповещения, управление хостами.
|
Центральная платформа: приём событий от агентов, корреляция, UI, оповещения, управление хостами.
|
||||||
|
|
||||||
@@ -8,12 +8,12 @@
|
|||||||
|
|
||||||
| Репозиторий | Назначение |
|
| Репозиторий | Назначение |
|
||||||
|-------------|------------|
|
|-------------|------------|
|
||||||
| [ssh-monitor](https://git.papatramp.ru/PapaTramp/ssh-monitor) | Linux-агент |
|
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux-агент |
|
||||||
| [RDP-login-monitor](https://git.papatramp.ru/PapaTramp/RDP-login-monitor) | Windows-агент |
|
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows-агент |
|
||||||
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
|
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
|
||||||
| [seaca](https://git.papatramp.ru/PapaTramp/seaca) | Android-клиент |
|
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android-клиент |
|
||||||
|
|
||||||
**Версия:** `0.5.16` · **Деплой:** `sudo /opt/sac-deploy.sh`
|
**Версия:** `0.5.0` · **Деплой:** `sudo /opt/sac-deploy.sh`
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
git clone https://git.kalinamall.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)
|
# 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
|
cd /opt/security-alert-center/backend && python3 -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
|
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
|
||||||
|
|||||||
+6
-6
@@ -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.
|
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 |
|
| Repository | Role |
|
||||||
|------------|------|
|
|------------|------|
|
||||||
| [ssh-monitor](https://git.papatramp.ru/PapaTramp/ssh-monitor) | Linux agent |
|
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Linux agent |
|
||||||
| [RDP-login-monitor](https://git.papatramp.ru/PapaTramp/RDP-login-monitor) | Windows agent |
|
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Windows agent |
|
||||||
| **security-alert-center** | SAC server (Ubuntu 24.04) |
|
| **security-alert-center** | SAC server (Ubuntu 24.04) |
|
||||||
| [seaca](https://git.papatramp.ru/PapaTramp/seaca) | Android client |
|
| [seaca](https://git.kalinamall.ru/PapaTramp/seaca) | Android client |
|
||||||
|
|
||||||
**Version:** `0.5.16` · **Deploy:** `sudo /opt/sac-deploy.sh`
|
**Version:** `0.5.0` · **Deploy:** `sudo /opt/sac-deploy.sh`
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ Self-hosted hub for security events from Linux and Windows agents: ingest, corre
|
|||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||||
# deploy/env.native.example → config/sac-api.env
|
# deploy/env.native.example → config/sac-api.env
|
||||||
cd /opt/security-alert-center/backend && python3 -m venv .venv
|
cd /opt/security-alert-center/backend && python3 -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
|
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -28,12 +28,12 @@ from app.services.host_sessions import (
|
|||||||
mark_event_session_terminated,
|
mark_event_session_terminated,
|
||||||
terminate_session_for_event,
|
terminate_session_for_event,
|
||||||
)
|
)
|
||||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||||
from app.services.ssh_connect import (
|
from app.services.ssh_connect import (
|
||||||
HostNotLinuxError as SshHostNotLinuxError,
|
HostNotLinuxError as SshHostNotLinuxError,
|
||||||
HostTargetMissingError as SshHostTargetMissingError,
|
HostTargetMissingError as SshHostTargetMissingError,
|
||||||
)
|
)
|
||||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||||
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
|
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
|
||||||
from app.services.ingest import ingest_event
|
from app.services.ingest import ingest_event
|
||||||
from app.services.event_summary import event_to_summary
|
from app.services.event_summary import event_to_summary
|
||||||
@@ -52,9 +52,7 @@ from app.services.notify_dispatch import (
|
|||||||
notify_lifecycle,
|
notify_lifecycle,
|
||||||
notify_problem,
|
notify_problem,
|
||||||
notify_rdg_connection,
|
notify_rdg_connection,
|
||||||
schedule_notify_auth_login,
|
|
||||||
schedule_notify_daily_report,
|
schedule_notify_daily_report,
|
||||||
schedule_notify_lifecycle,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/events", tags=["events"])
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
@@ -102,17 +100,15 @@ def post_event(
|
|||||||
problem = None
|
problem = None
|
||||||
problem_created = False
|
problem_created = False
|
||||||
deferred_daily_report_id: int | None = None
|
deferred_daily_report_id: int | None = None
|
||||||
deferred_lifecycle_id: int | None = None
|
|
||||||
deferred_auth_login_id: int | None = None
|
|
||||||
if created:
|
if created:
|
||||||
problem, problem_created = maybe_create_problem(db, event)
|
problem, problem_created = maybe_create_problem(db, event)
|
||||||
maybe_auto_disconnect_stuck_rdp_session(db, event)
|
maybe_auto_disconnect_stuck_rdp_session(db, event)
|
||||||
if event.type in DAILY_REPORT_EVENT_TYPES:
|
if event.type in DAILY_REPORT_EVENT_TYPES:
|
||||||
deferred_daily_report_id = event.id
|
deferred_daily_report_id = event.id
|
||||||
elif event.type == LIFECYCLE_EVENT_TYPE:
|
elif event.type == LIFECYCLE_EVENT_TYPE:
|
||||||
deferred_lifecycle_id = event.id
|
notify_lifecycle(event, db=db)
|
||||||
elif event.type in AUTH_LOGIN_SUCCESS_TYPES:
|
elif event.type in AUTH_LOGIN_SUCCESS_TYPES:
|
||||||
deferred_auth_login_id = event.id
|
notify_auth_login(event, db=db)
|
||||||
elif event.type in RDG_CONNECTION_TYPES:
|
elif event.type in RDG_CONNECTION_TYPES:
|
||||||
notify_rdg_connection(event, db=db)
|
notify_rdg_connection(event, db=db)
|
||||||
else:
|
else:
|
||||||
@@ -126,10 +122,6 @@ def post_event(
|
|||||||
|
|
||||||
if deferred_daily_report_id is not None:
|
if deferred_daily_report_id is not None:
|
||||||
background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id)
|
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)
|
body = _ingest_response(event, created=created)
|
||||||
if problem is not None:
|
if problem is not None:
|
||||||
@@ -284,10 +276,8 @@ def post_event_terminate_session(
|
|||||||
if event_session_terminated(event, db=db):
|
if event_session_terminated(event, db=db):
|
||||||
raise HTTPException(status_code=409, detail="Session already terminated for this event")
|
raise HTTPException(status_code=409, detail="Session already terminated for this event")
|
||||||
|
|
||||||
if event.host is None:
|
linux_cfg = get_effective_linux_admin_config(db)
|
||||||
raise HTTPException(status_code=400, detail="Event has no host")
|
win_cfg = get_effective_win_admin_config(db)
|
||||||
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)
|
sid = (body.session_id if body else None) or event_session_id(event)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+16
-111
@@ -46,12 +46,8 @@ from app.services.host_sessions import (
|
|||||||
terminate_windows_session,
|
terminate_windows_session,
|
||||||
)
|
)
|
||||||
from app.services.host_delete import delete_host_and_related
|
from app.services.host_delete import delete_host_and_related
|
||||||
from app.services.host_mgmt_credentials import (
|
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||||
get_host_mgmt_access_view,
|
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||||
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 (
|
from app.services.winrm_connect import (
|
||||||
HostNotWindowsError,
|
HostNotWindowsError,
|
||||||
HostTargetMissingError,
|
HostTargetMissingError,
|
||||||
@@ -303,23 +299,6 @@ class HostAgentConfigResponse(BaseModel):
|
|||||||
settings: dict[str, object]
|
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):
|
class HostRemoteActionStartResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
host_id: int
|
host_id: int
|
||||||
@@ -490,12 +469,9 @@ def test_host_winrm(
|
|||||||
if host is None:
|
if host is None:
|
||||||
raise HTTPException(status_code=404, detail="Host not found")
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
cfg = get_effective_win_admin_for_host(db, host)
|
cfg = get_effective_win_admin_config(db)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
|
||||||
status_code=400,
|
|
||||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
targets = iter_winrm_targets(host)
|
targets = iter_winrm_targets(host)
|
||||||
@@ -556,12 +532,9 @@ def test_host_ssh(
|
|||||||
if host is None:
|
if host is None:
|
||||||
raise HTTPException(status_code=404, detail="Host not found")
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
cfg = get_effective_linux_admin_for_host(db, host)
|
cfg = get_effective_linux_admin_config(db)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
||||||
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)
|
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
|
||||||
_set_ssh_admin_status(host, response.ok)
|
_set_ssh_admin_status(host, response.ok)
|
||||||
@@ -583,12 +556,9 @@ def update_host_agent_via_ssh(
|
|||||||
if host is None:
|
if host is None:
|
||||||
raise HTTPException(status_code=404, detail="Host not found")
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
cfg = get_effective_linux_admin_for_host(db, host)
|
cfg = get_effective_linux_admin_config(db)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
||||||
status_code=400,
|
|
||||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
|
||||||
)
|
|
||||||
|
|
||||||
title = "Обновление ssh-monitor (SSH)"
|
title = "Обновление ssh-monitor (SSH)"
|
||||||
try:
|
try:
|
||||||
@@ -597,7 +567,6 @@ def update_host_agent_via_ssh(
|
|||||||
host,
|
host,
|
||||||
title=title,
|
title=title,
|
||||||
runner=run_ssh_monitor_update_action,
|
runner=run_ssh_monitor_update_action,
|
||||||
poll_remote_log=True,
|
|
||||||
)
|
)
|
||||||
except RemoteActionAlreadyRunningError as exc:
|
except RemoteActionAlreadyRunningError as exc:
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
@@ -683,7 +652,6 @@ def get_host_remote_job(
|
|||||||
host = db.get(Host, host_id)
|
host = db.get(Host, host_id)
|
||||||
if host is None:
|
if host is None:
|
||||||
raise HTTPException(status_code=404, detail="Host not found")
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
db.refresh(host)
|
|
||||||
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
||||||
|
|
||||||
|
|
||||||
@@ -716,12 +684,9 @@ def list_host_sessions(
|
|||||||
from app.services.winrm_connect import is_windows_host
|
from app.services.winrm_connect import is_windows_host
|
||||||
|
|
||||||
if is_linux_host(host):
|
if is_linux_host(host):
|
||||||
cfg = get_effective_linux_admin_for_host(db, host)
|
cfg = get_effective_linux_admin_config(db)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
||||||
status_code=400,
|
|
||||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
sessions, result = list_linux_sessions(host, cfg)
|
sessions, result = list_linux_sessions(host, cfg)
|
||||||
except SshHostNotLinuxError as exc:
|
except SshHostNotLinuxError as exc:
|
||||||
@@ -736,12 +701,9 @@ def list_host_sessions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if is_windows_host(host):
|
if is_windows_host(host):
|
||||||
cfg = get_effective_win_admin_for_host(db, host)
|
cfg = get_effective_win_admin_config(db)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
|
||||||
status_code=400,
|
|
||||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
sessions, result = list_windows_sessions(host, cfg)
|
sessions, result = list_windows_sessions(host, cfg)
|
||||||
except HostNotWindowsError as exc:
|
except HostNotWindowsError as exc:
|
||||||
@@ -779,12 +741,9 @@ def terminate_host_session(
|
|||||||
raise HTTPException(status_code=422, detail="session_id is required")
|
raise HTTPException(status_code=422, detail="session_id is required")
|
||||||
|
|
||||||
if is_linux_host(host):
|
if is_linux_host(host):
|
||||||
cfg = get_effective_linux_admin_for_host(db, host)
|
cfg = get_effective_linux_admin_config(db)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
||||||
status_code=400,
|
|
||||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
result = terminate_linux_session(host, cfg, sid)
|
result = terminate_linux_session(host, cfg, sid)
|
||||||
except SshHostNotLinuxError as exc:
|
except SshHostNotLinuxError as exc:
|
||||||
@@ -800,12 +759,9 @@ def terminate_host_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if is_windows_host(host):
|
if is_windows_host(host):
|
||||||
cfg = get_effective_win_admin_for_host(db, host)
|
cfg = get_effective_win_admin_config(db)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
|
||||||
status_code=400,
|
|
||||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
result = terminate_windows_session(host, cfg, sid)
|
result = terminate_windows_session(host, cfg, sid)
|
||||||
except HostNotWindowsError as exc:
|
except HostNotWindowsError as exc:
|
||||||
@@ -845,57 +801,6 @@ 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)
|
@router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||||
def get_host_agent_config(
|
def get_host_agent_config(
|
||||||
host_id: int,
|
host_id: int,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import os
|
import os
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -36,7 +36,6 @@ class Settings(BaseSettings):
|
|||||||
database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac"
|
database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac"
|
||||||
sac_db_pool_size: int = 15
|
sac_db_pool_size: int = 15
|
||||||
sac_db_max_overflow: int = 25
|
sac_db_max_overflow: int = 25
|
||||||
sac_uvicorn_workers: int = 4
|
|
||||||
sac_public_url: str = "http://localhost:8000"
|
sac_public_url: str = "http://localhost:8000"
|
||||||
# URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL.
|
# URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL.
|
||||||
sac_agent_bundle_base_url: str = ""
|
sac_agent_bundle_base_url: str = ""
|
||||||
@@ -138,8 +137,8 @@ class Settings(BaseSettings):
|
|||||||
sac_agent_min_rdp_version: str = ""
|
sac_agent_min_rdp_version: str = ""
|
||||||
sac_agent_min_ssh_version: str = ""
|
sac_agent_min_ssh_version: str = ""
|
||||||
sac_win_agent_update_script: str = ""
|
sac_win_agent_update_script: str = ""
|
||||||
sac_agent_rdp_git_repo_url: str = "https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
|
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.papatramp.ru/PapaTramp/ssh-monitor.git"
|
sac_agent_ssh_git_repo_url: str = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
|
||||||
sac_agent_git_branch: str = "main"
|
sac_agent_git_branch: str = "main"
|
||||||
sac_agent_git_cache_dir: str = "/opt/security-alert-center/cache/agent-repos"
|
sac_agent_git_cache_dir: str = "/opt/security-alert-center/cache/agent-repos"
|
||||||
sac_agent_git_cache_ttl_minutes: int = 30
|
sac_agent_git_cache_ttl_minutes: int = 30
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
|
|||||||
# RDP / Windows
|
# RDP / Windows
|
||||||
"rdp.login.success": "info",
|
"rdp.login.success": "info",
|
||||||
"rdp.login.failed": "warning",
|
"rdp.login.failed": "warning",
|
||||||
"rdp.session.logoff": "info",
|
|
||||||
"rdp.shadow.control.started": "warning",
|
"rdp.shadow.control.started": "warning",
|
||||||
"rdp.shadow.control.stopped": "info",
|
"rdp.shadow.control.stopped": "info",
|
||||||
"rdp.shadow.control.permission": "warning",
|
"rdp.shadow.control.permission": "warning",
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ class Host(Base):
|
|||||||
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
||||||
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||||
ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=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_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_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
pending_update_target_version: Mapped[str | None] = mapped_column(String(64))
|
pending_update_target_version: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ class EventSummary(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
summary: str
|
summary: str
|
||||||
actor_user: str | None = None
|
actor_user: str | None = None
|
||||||
session_duration_sec: int | None = None
|
|
||||||
rdg_flap: bool = False
|
rdg_flap: bool = False
|
||||||
rdg_flap_pair_event_id: int | None = None
|
rdg_flap_pair_event_id: int | None = None
|
||||||
rdg_flap_qwinsta_event_id: int | None = None
|
rdg_flap_qwinsta_event_id: int | None = None
|
||||||
|
|||||||
@@ -7,22 +7,17 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.models import AgentCommand, Host
|
from app.models import AgentCommand, Host
|
||||||
from app.services.win_admin_settings import (
|
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||||
get_effective_win_admin_config,
|
|
||||||
get_effective_win_admin_for_host,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _win_admin_configured() -> bool:
|
def _win_admin_configured() -> bool:
|
||||||
return get_effective_win_admin_config().configured
|
return get_effective_win_admin_config().configured
|
||||||
|
|
||||||
|
|
||||||
def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dict[str, str] | None:
|
def win_admin_run_as() -> dict[str, str] | None:
|
||||||
if host is not None and db is not None:
|
cfg = get_effective_win_admin_config()
|
||||||
cfg = get_effective_win_admin_for_host(db, host)
|
|
||||||
else:
|
|
||||||
cfg = get_effective_win_admin_config(db)
|
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
@@ -31,13 +26,7 @@ def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dic
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def command_to_dict(
|
def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict:
|
||||||
cmd: AgentCommand,
|
|
||||||
*,
|
|
||||||
include_run_as: bool = False,
|
|
||||||
db: Session | None = None,
|
|
||||||
host: Host | None = None,
|
|
||||||
) -> dict:
|
|
||||||
out = {
|
out = {
|
||||||
"id": cmd.command_uuid,
|
"id": cmd.command_uuid,
|
||||||
"type": cmd.command_type,
|
"type": cmd.command_type,
|
||||||
@@ -49,7 +38,7 @@ def command_to_dict(
|
|||||||
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
|
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
|
||||||
}
|
}
|
||||||
if include_run_as and cmd.status == "pending":
|
if include_run_as and cmd.status == "pending":
|
||||||
run_as = win_admin_run_as(db, host)
|
run_as = win_admin_run_as()
|
||||||
if run_as:
|
if run_as:
|
||||||
out["run_as"] = run_as
|
out["run_as"] = run_as
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -27,5 +27,5 @@ def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]:
|
|||||||
"config_revision": int(host.agent_config_revision or 0),
|
"config_revision": int(host.agent_config_revision or 0),
|
||||||
"config": config_payload,
|
"config": config_payload,
|
||||||
"update": update_block,
|
"update": update_block,
|
||||||
"commands": [command_to_dict(c, include_run_as=True, db=db, host=host) for c in pending],
|
"commands": [command_to_dict(c, include_run_as=True) for c in pending],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.services.agent_version import (
|
|||||||
host_version_outdated,
|
host_version_outdated,
|
||||||
latest_agent_versions_by_product,
|
latest_agent_versions_by_product,
|
||||||
)
|
)
|
||||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||||
from app.services.ssh_connect import (
|
from app.services.ssh_connect import (
|
||||||
HostNotLinuxError,
|
HostNotLinuxError,
|
||||||
HostTargetMissingError,
|
HostTargetMissingError,
|
||||||
@@ -28,7 +28,7 @@ from app.services.ssh_connect import (
|
|||||||
iter_ssh_targets,
|
iter_ssh_targets,
|
||||||
run_ssh_monitor_update,
|
run_ssh_monitor_update,
|
||||||
)
|
)
|
||||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||||
from app.services.winrm_connect import (
|
from app.services.winrm_connect import (
|
||||||
HostNotWindowsError,
|
HostNotWindowsError,
|
||||||
HostTargetMissingError as WinRmHostTargetMissingError,
|
HostTargetMissingError as WinRmHostTargetMissingError,
|
||||||
@@ -249,7 +249,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbac
|
|||||||
product = (host.product or "").strip()
|
product = (host.product or "").strip()
|
||||||
|
|
||||||
if product == PRODUCT_SSH or is_linux_host(host):
|
if product == PRODUCT_SSH or is_linux_host(host):
|
||||||
linux_cfg = get_effective_linux_admin_for_host(db, host)
|
linux_cfg = get_effective_linux_admin_config(db)
|
||||||
agent_cfg = get_effective_agent_update_config(db)
|
agent_cfg = get_effective_agent_update_config(db)
|
||||||
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
||||||
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
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,
|
git_branch=branch,
|
||||||
)
|
)
|
||||||
elif product == PRODUCT_RDP or is_windows_host(host):
|
elif product == PRODUCT_RDP or is_windows_host(host):
|
||||||
win_cfg = get_effective_win_admin_for_host(db, host)
|
win_cfg = get_effective_win_admin_config(db)
|
||||||
result = run_windows_agent_update_fallback(
|
result = run_windows_agent_update_fallback(
|
||||||
host,
|
host,
|
||||||
win_cfg,
|
win_cfg,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ ACTOR_USER_EVENT_TYPES: frozenset[str] = frozenset(
|
|||||||
"session.logind.failed",
|
"session.logind.failed",
|
||||||
"rdp.login.success",
|
"rdp.login.success",
|
||||||
"rdp.login.failed",
|
"rdp.login.failed",
|
||||||
"rdp.session.logoff",
|
|
||||||
"rdp.shadow.control.started",
|
"rdp.shadow.control.started",
|
||||||
"rdp.shadow.control.stopped",
|
"rdp.shadow.control.stopped",
|
||||||
"rdp.shadow.control.permission",
|
"rdp.shadow.control.permission",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from app.services.event_actor_user import extract_event_actor_user
|
|||||||
from app.services.host_sessions import event_session_terminated
|
from app.services.host_sessions import event_session_terminated
|
||||||
from app.services.rdg_display import build_rdg_display
|
from app.services.rdg_display import build_rdg_display
|
||||||
from app.services.rdg_session_flap import resolve_rdg_flap_summary
|
from app.services.rdg_session_flap import resolve_rdg_flap_summary
|
||||||
from app.services.session_duration import extract_session_duration_sec
|
|
||||||
|
|
||||||
|
|
||||||
def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
|
def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
|
||||||
@@ -45,9 +44,6 @@ def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
|
|||||||
title=title,
|
title=title,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
actor_user=extract_event_actor_user(event.type, event.details),
|
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=rdg_flap,
|
||||||
rdg_flap_pair_event_id=rdg_flap_pair_event_id,
|
rdg_flap_pair_event_id=rdg_flap_pair_event_id,
|
||||||
rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
|
rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
"""Per-host management credentials (override global Win/Linux admin)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.host import Host
|
|
||||||
from app.services.linux_admin_settings import (
|
|
||||||
LinuxAdminConfig,
|
|
||||||
get_effective_linux_admin_for_host,
|
|
||||||
)
|
|
||||||
from app.services.win_admin_settings import (
|
|
||||||
WinAdminConfig,
|
|
||||||
get_effective_win_admin_for_host,
|
|
||||||
normalize_win_admin_user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def mask_secret(value: str | None) -> str | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
text = value.strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
if len(text) <= 4:
|
|
||||||
return "****"
|
|
||||||
return f"{text[:2]}…{text[-2:]}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class HostMgmtAccessView:
|
|
||||||
host_id: int
|
|
||||||
has_override: bool
|
|
||||||
user: str | None
|
|
||||||
password_set: bool
|
|
||||||
password_hint: str | None
|
|
||||||
effective_source: str
|
|
||||||
effective_configured: bool
|
|
||||||
effective_user: str | None
|
|
||||||
|
|
||||||
|
|
||||||
def _effective_for_host(db: Session, host: Host) -> WinAdminConfig | LinuxAdminConfig:
|
|
||||||
os_family = (host.os_family or "").strip().lower()
|
|
||||||
if os_family == "windows" or (host.product or "").strip().lower() in {"rdp-login-monitor", "rdp"}:
|
|
||||||
return get_effective_win_admin_for_host(db, host)
|
|
||||||
if os_family == "linux" or (host.product or "").strip().lower() in {"ssh-monitor", "ssh"}:
|
|
||||||
return get_effective_linux_admin_for_host(db, host)
|
|
||||||
# Fallback: try host override first via win helper (same columns), then global win, then linux.
|
|
||||||
win_cfg = get_effective_win_admin_for_host(db, host)
|
|
||||||
if win_cfg.configured:
|
|
||||||
return win_cfg
|
|
||||||
return get_effective_linux_admin_for_host(db, host)
|
|
||||||
|
|
||||||
|
|
||||||
def get_host_mgmt_access_view(db: Session, host: Host) -> HostMgmtAccessView:
|
|
||||||
host_user = (host.mgmt_user or "").strip() or None
|
|
||||||
host_password = (host.mgmt_password or "").strip() or None
|
|
||||||
has_override = bool(host_user or host_password)
|
|
||||||
effective = _effective_for_host(db, host)
|
|
||||||
return HostMgmtAccessView(
|
|
||||||
host_id=host.id,
|
|
||||||
has_override=has_override,
|
|
||||||
user=host_user,
|
|
||||||
password_set=bool(host_password),
|
|
||||||
password_hint=mask_secret(host_password),
|
|
||||||
effective_source=effective.source,
|
|
||||||
effective_configured=effective.configured,
|
|
||||||
effective_user=effective.user or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_host_mgmt_credentials(
|
|
||||||
db: Session,
|
|
||||||
host: Host,
|
|
||||||
*,
|
|
||||||
user: str | None = None,
|
|
||||||
password: str | None = None,
|
|
||||||
clear: bool = False,
|
|
||||||
) -> HostMgmtAccessView:
|
|
||||||
"""Update per-host credentials.
|
|
||||||
|
|
||||||
- clear=True → wipe host override (use global Settings).
|
|
||||||
- user="" → clear username.
|
|
||||||
- password omitted (None) → keep existing password.
|
|
||||||
- password="" → clear password.
|
|
||||||
"""
|
|
||||||
if clear:
|
|
||||||
host.mgmt_user = None
|
|
||||||
host.mgmt_password = None
|
|
||||||
db.commit()
|
|
||||||
db.refresh(host)
|
|
||||||
return get_host_mgmt_access_view(db, host)
|
|
||||||
|
|
||||||
if user is not None:
|
|
||||||
cleaned = user.strip()
|
|
||||||
if not cleaned:
|
|
||||||
host.mgmt_user = None
|
|
||||||
else:
|
|
||||||
# Preserve DOMAIN\\user form for Windows; for Linux leave as-is after strip.
|
|
||||||
os_family = (host.os_family or "").strip().lower()
|
|
||||||
if os_family == "windows" or "\\" in cleaned or "@" in cleaned:
|
|
||||||
host.mgmt_user = normalize_win_admin_user(cleaned)
|
|
||||||
else:
|
|
||||||
host.mgmt_user = cleaned
|
|
||||||
|
|
||||||
if password is not None:
|
|
||||||
if not password.strip():
|
|
||||||
host.mgmt_password = None
|
|
||||||
else:
|
|
||||||
host.mgmt_password = password
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(host)
|
|
||||||
return get_host_mgmt_access_view(db, host)
|
|
||||||
@@ -14,14 +14,9 @@ from app.database import SessionLocal
|
|||||||
from app.models import Host
|
from app.models import Host
|
||||||
from app.services.agent_update import execute_agent_update_fallback
|
from app.services.agent_update import execute_agent_update_fallback
|
||||||
from app.services.agent_update_types import AgentUpdateFallbackResult
|
from app.services.agent_update_types import AgentUpdateFallbackResult
|
||||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||||
from app.services.agent_update_settings import get_effective_agent_update_config
|
from app.services.agent_update_settings import get_effective_agent_update_config
|
||||||
from app.services.ssh_connect import (
|
from app.services.ssh_connect import iter_ssh_targets, run_ssh_monitor_update, SshCommandResult
|
||||||
iter_ssh_targets,
|
|
||||||
run_ssh_monitor_update,
|
|
||||||
SshCommandResult,
|
|
||||||
tail_ssh_monitor_update_log,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -81,61 +76,6 @@ 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:
|
def _mark_running(db: Session, host: Host, *, title: str) -> str:
|
||||||
started_at = _utcnow().isoformat()
|
started_at = _utcnow().isoformat()
|
||||||
host.agent_update_state = "running"
|
host.agent_update_state = "running"
|
||||||
@@ -173,70 +113,12 @@ def _apply_ssh_update_result(db: Session, host: Host, result: SshCommandResult)
|
|||||||
host.agent_update_last_error = (result.message or "SSH update failed")[:2000]
|
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(
|
def start_host_remote_action(
|
||||||
db: Session,
|
db: Session,
|
||||||
host: Host,
|
host: Host,
|
||||||
*,
|
*,
|
||||||
title: str,
|
title: str,
|
||||||
runner: ActionRunner,
|
runner: ActionRunner,
|
||||||
poll_remote_log: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
host_id = int(host.id)
|
host_id = int(host.id)
|
||||||
with _lock:
|
with _lock:
|
||||||
@@ -251,35 +133,13 @@ def start_host_remote_action(
|
|||||||
|
|
||||||
def _worker() -> None:
|
def _worker() -> None:
|
||||||
session = SessionLocal()
|
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:
|
try:
|
||||||
row = session.get(Host, host_id)
|
row = session.get(Host, host_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
return
|
return
|
||||||
previous_output = (row.remote_action or {}).get("output") or ""
|
|
||||||
result = runner(session, row)
|
result = runner(session, row)
|
||||||
started = (row.remote_action or {}).get("started_at") or started_at
|
started = (row.remote_action or {}).get("started_at") or started_at
|
||||||
payload = _result_payload(result, title=title, started_at=started)
|
row.remote_action = _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):
|
if isinstance(result, SshCommandResult):
|
||||||
_apply_ssh_update_result(session, row, result)
|
_apply_ssh_update_result(session, row, result)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -307,9 +167,6 @@ def start_host_remote_action(
|
|||||||
}
|
}
|
||||||
session.commit()
|
session.commit()
|
||||||
finally:
|
finally:
|
||||||
stop_poll.set()
|
|
||||||
if poll_thread is not None:
|
|
||||||
poll_thread.join(timeout=5.0)
|
|
||||||
session.close()
|
session.close()
|
||||||
with _lock:
|
with _lock:
|
||||||
_running.discard(host_id)
|
_running.discard(host_id)
|
||||||
@@ -322,7 +179,7 @@ def start_host_remote_action(
|
|||||||
|
|
||||||
|
|
||||||
def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult:
|
def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult:
|
||||||
cfg = get_effective_linux_admin_for_host(db, host)
|
cfg = get_effective_linux_admin_config(db)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
return SshCommandResult(
|
return SshCommandResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
@@ -419,16 +276,8 @@ def get_remote_action_status(host: Host) -> dict[str, Any]:
|
|||||||
payload = dict(host.remote_action or {})
|
payload = dict(host.remote_action or {})
|
||||||
if not payload:
|
if not payload:
|
||||||
return {"active": False, "host_id": host.id}
|
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"
|
status = payload.get("status") or host.agent_update_state or "unknown"
|
||||||
active = status == "running"
|
active = status == "running" or host.agent_update_state == "running"
|
||||||
return {
|
return {
|
||||||
"active": active,
|
"active": active,
|
||||||
"host_id": host.id,
|
"host_id": host.id,
|
||||||
|
|||||||
@@ -84,10 +84,6 @@ def _event_login_user(event: Event) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def event_session_terminated(event: Event, db: Session | None = None) -> bool:
|
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 (
|
from app.services.rdg_workstation_session import (
|
||||||
event_closed_by_rdg,
|
event_closed_by_rdg,
|
||||||
resolve_workstation_login_closed,
|
resolve_workstation_login_closed,
|
||||||
@@ -101,11 +97,7 @@ def event_session_terminated(event: Event, db: Session | None = None) -> bool:
|
|||||||
return True
|
return True
|
||||||
if event_closed_by_rdg(event):
|
if event_closed_by_rdg(event):
|
||||||
return True
|
return True
|
||||||
if event_closed_by_logoff(event):
|
|
||||||
return True
|
|
||||||
if db is not None and event.type == "rdp.login.success":
|
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 resolve_workstation_login_closed(db, event)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -384,79 +376,28 @@ def terminate_linux_session(
|
|||||||
return last
|
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]:
|
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] = []
|
rows: list[HostSessionRow] = []
|
||||||
filter_sam = _qwinsta_sam_account(filter_user or "")
|
norm_filter = (filter_user or "").strip().lower()
|
||||||
skip_users = frozenset({"services"})
|
|
||||||
|
def norm_user(value: str) -> str:
|
||||||
|
return value.replace("B26\\", "").replace("b26\\", "").lower()
|
||||||
|
|
||||||
for line in stdout.splitlines():
|
for line in stdout.splitlines():
|
||||||
text = line.strip()
|
text = line.strip()
|
||||||
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
|
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
|
||||||
continue
|
continue
|
||||||
parts = text.split()
|
parts = text.split()
|
||||||
id_idx: int | None = None
|
if len(parts) < 4:
|
||||||
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
|
continue
|
||||||
state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else ""
|
session_name = parts[0].lstrip(">")
|
||||||
if not state or state.casefold().startswith("listen"):
|
user_name = parts[1]
|
||||||
|
try:
|
||||||
|
sid = int(parts[2])
|
||||||
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
before = parts[:id_idx]
|
state = " ".join(parts[3:])
|
||||||
if len(before) == 1:
|
if norm_filter and norm_filter not in norm_user(user_name):
|
||||||
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
|
continue
|
||||||
rows.append(
|
rows.append(
|
||||||
HostSessionRow(
|
HostSessionRow(
|
||||||
@@ -467,7 +408,7 @@ def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> li
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if rows or not filter_sam:
|
if rows or not norm_filter:
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
return parse_qwinsta_sessions(stdout, filter_user=None)
|
return parse_qwinsta_sessions(stdout, filter_user=None)
|
||||||
@@ -534,17 +475,11 @@ def terminate_session_for_event(
|
|||||||
sessions, qwinsta = list_windows_sessions(host, win_cfg)
|
sessions, qwinsta = list_windows_sessions(host, win_cfg)
|
||||||
if not qwinsta or not qwinsta.ok:
|
if not qwinsta or not qwinsta.ok:
|
||||||
raise ValueError(qwinsta.message if qwinsta else "qwinsta failed")
|
raise ValueError(qwinsta.message if qwinsta else "qwinsta failed")
|
||||||
matched = filter_windows_sessions_for_user(sessions, user) if user else sessions
|
matched = [s for s in sessions if user.lower() in s.user.lower()] if user else sessions
|
||||||
if len(matched) == 1:
|
if len(matched) == 1:
|
||||||
sid = matched[0].session_id
|
sid = matched[0].session_id
|
||||||
elif not matched:
|
elif not matched:
|
||||||
# Пользователь уже вышел из RDP — qwinsta пуст, событие входа в SAC ещё «открыто».
|
raise ValueError("No matching Windows session for user")
|
||||||
return WinRmCmdResult(
|
|
||||||
ok=True,
|
|
||||||
message="На хосте нет активной сессии пользователя (уже вышел из RDP)",
|
|
||||||
target=qwinsta.target,
|
|
||||||
stdout=qwinsta.stdout,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError("Multiple sessions; specify session_id")
|
raise ValueError("Multiple sessions; specify session_id")
|
||||||
result = terminate_windows_session(host, win_cfg, sid)
|
result = terminate_windows_session(host, win_cfg, sid)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from app.services.agent_update import process_agent_update_ingest
|
|||||||
from app.services.daily_report_format import normalize_daily_report_details
|
from app.services.daily_report_format import normalize_daily_report_details
|
||||||
from app.services.event_severity_overrides import apply_severity_override
|
from app.services.event_severity_overrides import apply_severity_override
|
||||||
from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest
|
from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest
|
||||||
from app.services.rdp_session_logoff import close_workstation_session_for_rdp_logoff
|
|
||||||
from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end
|
from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end
|
||||||
|
|
||||||
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
||||||
@@ -128,15 +127,5 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
process_agent_update_ingest(db, host, payload.get("type", ""), details)
|
process_agent_update_ingest(db, host, payload.get("type", ""), details)
|
||||||
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_rdg_end(db, event)
|
||||||
close_workstation_session_for_rdp_logoff(db, event)
|
|
||||||
return event, True
|
return event, True
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Effective Linux SSH admin credentials (host → DB → env)."""
|
"""Effective Linux SSH admin credentials (DB overrides env)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,7 +7,6 @@ from dataclasses import dataclass
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models.host import Host
|
|
||||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
|||||||
class LinuxAdminConfig:
|
class LinuxAdminConfig:
|
||||||
user: str
|
user: str
|
||||||
password: str
|
password: str
|
||||||
source: str # host | env | db
|
source: str # env | db
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
@@ -81,16 +80,3 @@ def upsert_linux_admin_settings(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return get_effective_linux_admin_config(db)
|
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
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import logging
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Event, Host, Problem
|
from app.models import Event, Problem
|
||||||
from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify
|
from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify
|
||||||
from app.services.notification_cooldown import should_notify_event, should_notify_problem
|
from app.services.notification_cooldown import should_notify_event, should_notify_problem
|
||||||
from app.services.notification_policy import get_effective_notification_policy
|
from app.services.notification_policy import get_effective_notification_policy
|
||||||
@@ -15,14 +15,6 @@ from app.services.notification_severity import severity_meets_minimum
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
LIFECYCLE_EVENT_TYPE = "agent.lifecycle"
|
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"})
|
DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
||||||
AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"})
|
AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"})
|
||||||
RDG_CONNECTION_TYPES = frozenset({
|
RDG_CONNECTION_TYPES = frozenset({
|
||||||
@@ -75,8 +67,6 @@ def notify_event(event: Event, *, db: Session | None = None) -> None:
|
|||||||
# Heartbeat — только для UI/статуса хоста, не для Telegram/email/push.
|
# Heartbeat — только для UI/статуса хоста, не для Telegram/email/push.
|
||||||
if event.type == HEARTBEAT_TYPE:
|
if event.type == HEARTBEAT_TYPE:
|
||||||
return
|
return
|
||||||
if _should_suppress_sudo_notify(event, db=db):
|
|
||||||
return
|
|
||||||
if _skip_notifications_for_hidden_event(event, db):
|
if _skip_notifications_for_hidden_event(event, db):
|
||||||
return
|
return
|
||||||
policy = get_effective_notification_policy(db)
|
policy = get_effective_notification_policy(db)
|
||||||
@@ -121,68 +111,8 @@ def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
|
|||||||
_dispatch_lifecycle_channels(event, db=db, policy=policy)
|
_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:
|
def notify_lifecycle(event: Event, *, db: Session | None = None) -> None:
|
||||||
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
|
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
|
||||||
if _lifecycle_suppress_notifications(event, db=db):
|
|
||||||
return
|
|
||||||
if _skip_notifications_for_hidden_event(event, db):
|
if _skip_notifications_for_hidden_event(event, db):
|
||||||
return
|
return
|
||||||
policy = get_effective_notification_policy(db)
|
policy = get_effective_notification_policy(db)
|
||||||
@@ -216,30 +146,16 @@ def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None:
|
|||||||
|
|
||||||
def schedule_notify_daily_report(event_db_id: int) -> None:
|
def schedule_notify_daily_report(event_db_id: int) -> None:
|
||||||
"""Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST)."""
|
"""Отложенное оповещение по суточному отчёту (после 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
|
from app.database import SessionLocal
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
event = db.get(Event, event_db_id)
|
event = db.get(Event, event_db_id)
|
||||||
if event is None:
|
if event is None:
|
||||||
logger.warning("deferred %s notify: event id=%s not found", label, event_db_id)
|
logger.warning("deferred daily report notify: event id=%s not found", event_db_id)
|
||||||
return
|
return
|
||||||
handler(event, db=db)
|
notify_daily_report(event, db=db)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("deferred %s notify failed event_db_id=%s", label, event_db_id)
|
logger.exception("deferred daily report notify failed event_db_id=%s", event_db_id)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -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_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
|
||||||
from app.services.rdg_display import event_supports_rdg_client_qwinsta
|
from app.services.rdg_display import event_supports_rdg_client_qwinsta
|
||||||
from app.services.rdg_session_flap import event_internal_ip
|
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.win_admin_settings import get_effective_win_admin_config
|
||||||
from app.services.winrm_connect import (
|
from app.services.winrm_connect import (
|
||||||
WinRmCmdResult,
|
WinRmCmdResult,
|
||||||
run_winrm_logoff,
|
run_winrm_logoff,
|
||||||
@@ -21,17 +21,12 @@ from app.services.winrm_connect import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require_win_admin(db: Session, host: Host | None = None):
|
def _require_win_admin(db: Session):
|
||||||
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)
|
cfg = get_effective_win_admin_config(db)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
|
||||||
)
|
)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
@@ -98,8 +93,8 @@ def _persist_command(
|
|||||||
|
|
||||||
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
||||||
_require_rdg_client_qwinsta(db, event)
|
_require_rdg_client_qwinsta(db, event)
|
||||||
|
cfg = _require_win_admin(db)
|
||||||
client_host = _resolve_client(db, event)
|
client_host = _resolve_client(db, event)
|
||||||
cfg = _require_win_admin(db, client_host)
|
|
||||||
|
|
||||||
details = event.details if isinstance(event.details, dict) else {}
|
details = event.details if isinstance(event.details, dict) else {}
|
||||||
user = details.get("user")
|
user = details.get("user")
|
||||||
@@ -141,8 +136,8 @@ def execute_logoff_via_winrm(
|
|||||||
requested_by: str,
|
requested_by: str,
|
||||||
) -> AgentCommand:
|
) -> AgentCommand:
|
||||||
_require_rdg_client_qwinsta(db, event)
|
_require_rdg_client_qwinsta(db, event)
|
||||||
|
cfg = _require_win_admin(db)
|
||||||
client_host = _resolve_client(db, event)
|
client_host = _resolve_client(db, event)
|
||||||
cfg = _require_win_admin(db, client_host)
|
|
||||||
|
|
||||||
details = event.details if isinstance(event.details, dict) else {}
|
details = event.details if isinstance(event.details, dict) else {}
|
||||||
user = details.get("user")
|
user = details.get("user")
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta, timezone
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
@@ -17,7 +15,6 @@ from app.services.host_sessions import (
|
|||||||
from app.services.rdg_client_host import find_windows_host_by_ipv4
|
from app.services.rdg_client_host import find_windows_host_by_ipv4
|
||||||
from app.services.rdg_session_flap import (
|
from app.services.rdg_session_flap import (
|
||||||
RDG_END_TYPES,
|
RDG_END_TYPES,
|
||||||
RDG_SUCCESS_TYPE,
|
|
||||||
_event_user,
|
_event_user,
|
||||||
event_internal_ip,
|
event_internal_ip,
|
||||||
find_rdg_success_before_end,
|
find_rdg_success_before_end,
|
||||||
@@ -25,22 +22,10 @@ from app.services.rdg_session_flap import (
|
|||||||
|
|
||||||
SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at"
|
SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at"
|
||||||
SESSION_CLOSED_BY_RDG_EVENT_ID_KEY = "session_closed_by_rdg_event_id"
|
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"
|
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:
|
def normalize_sam_account(user: str) -> str:
|
||||||
text = (user or "").strip()
|
text = (user or "").strip()
|
||||||
if "\\" in text:
|
if "\\" in text:
|
||||||
@@ -56,115 +41,6 @@ def users_match_rdg(login_user: str, rdg_user: str) -> bool:
|
|||||||
return bool(left and right and left == right)
|
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:
|
def event_closed_by_rdg(event: Event) -> bool:
|
||||||
details = _details_dict(event)
|
details = _details_dict(event)
|
||||||
at = details.get(SESSION_CLOSED_BY_RDG_AT_KEY)
|
at = details.get(SESSION_CLOSED_BY_RDG_AT_KEY)
|
||||||
@@ -180,17 +56,13 @@ def mark_login_closed_by_rdg(login_event: Event, *, rdg_end_event: Event) -> Non
|
|||||||
|
|
||||||
|
|
||||||
def _login_already_closed(login_event: Event) -> bool:
|
def _login_already_closed(login_event: Event) -> bool:
|
||||||
from app.services.rdp_session_logoff import event_closed_by_logoff
|
|
||||||
|
|
||||||
details = _details_dict(login_event)
|
details = _details_dict(login_event)
|
||||||
if details.get("session_terminated") is True:
|
if details.get("session_terminated") is True:
|
||||||
return True
|
return True
|
||||||
at = details.get(SESSION_TERMINATED_AT_KEY)
|
at = details.get(SESSION_TERMINATED_AT_KEY)
|
||||||
if at is not None and str(at).strip() != "":
|
if at is not None and str(at).strip() != "":
|
||||||
return True
|
return True
|
||||||
if event_closed_by_rdg(login_event):
|
return 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:
|
def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
|
||||||
@@ -222,11 +94,8 @@ def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Eve
|
|||||||
for login in candidates:
|
for login in candidates:
|
||||||
if _login_already_closed(login):
|
if _login_already_closed(login):
|
||||||
continue
|
continue
|
||||||
login_user = (_event_login_user(login) or "").strip()
|
login_user = _event_login_user(login)
|
||||||
if login_user in ("", "-"):
|
if not users_match_rdg(login_user, rdg_user):
|
||||||
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
|
continue
|
||||||
return login
|
return login
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from app.services.rdg_workstation_session import (
|
|||||||
users_match_rdg,
|
users_match_rdg,
|
||||||
)
|
)
|
||||||
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings
|
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
|
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||||
|
|
||||||
logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
|
logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ def _disconnect_on_workstation(
|
|||||||
user: str,
|
user: str,
|
||||||
login_event: Event | None,
|
login_event: Event | None,
|
||||||
) -> AutoDisconnectResult:
|
) -> AutoDisconnectResult:
|
||||||
win_cfg = get_effective_win_admin_for_host(db, workstation)
|
win_cfg = get_effective_win_admin_config(db)
|
||||||
sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
|
sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
|
||||||
if qwinsta is None or not qwinsta.ok:
|
if qwinsta is None or not qwinsta.ok:
|
||||||
message = qwinsta.message if qwinsta else "qwinsta failed"
|
message = qwinsta.message if qwinsta else "qwinsta failed"
|
||||||
@@ -155,23 +155,13 @@ def _disconnect_on_workstation(
|
|||||||
login_event_id=login_event.id if login_event else None,
|
login_event_id=login_event.id if login_event else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
parsed = parse_qwinsta_sessions(qwinsta.stdout, filter_user=user)
|
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout, filter_user=user), user)
|
||||||
matched = _sessions_for_user(parsed, user)
|
|
||||||
if not matched and qwinsta.stdout.strip():
|
if not matched and qwinsta.stdout.strip():
|
||||||
parsed = parse_qwinsta_sessions(qwinsta.stdout)
|
matched = _sessions_for_user(parse_qwinsta_sessions(qwinsta.stdout), user)
|
||||||
matched = _sessions_for_user(parsed, user)
|
|
||||||
if not matched:
|
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(
|
return AutoDisconnectResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
message=message,
|
message="No matching Windows session for user",
|
||||||
trigger_event_id=trigger_event.id,
|
trigger_event_id=trigger_event.id,
|
||||||
workstation_host_id=workstation.id,
|
workstation_host_id=workstation.id,
|
||||||
login_event_id=login_event.id if login_event else None,
|
login_event_id=login_event.id if login_event else None,
|
||||||
@@ -234,13 +224,11 @@ def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult
|
|||||||
try:
|
try:
|
||||||
workstation = resolve_client_workstation(db, rdg_success)
|
workstation = resolve_client_workstation(db, rdg_success)
|
||||||
except ClientWorkstationNotFoundError as exc:
|
except ClientWorkstationNotFoundError as exc:
|
||||||
result = AutoDisconnectResult(
|
return AutoDisconnectResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
message=str(exc),
|
message=str(exc),
|
||||||
trigger_event_id=event.id,
|
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)
|
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
|
login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None
|
||||||
@@ -291,6 +279,14 @@ def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDi
|
|||||||
if not get_effective_rdp_flap_settings(db).auto_disconnect:
|
if not get_effective_rdp_flap_settings(db).auto_disconnect:
|
||||||
return None
|
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)
|
result = _auto_disconnect_rdg_flap(db, event)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
if result.ok:
|
if result.ok:
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""SSH connectivity and remote commands for Linux hosts."""
|
"""SSH connectivity and remote commands for Linux hosts."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,12 +9,10 @@ from dataclasses import dataclass
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models import Host
|
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_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
|
||||||
SSH_MONITOR_UPDATE_DIR = "/opt/scripts/update"
|
SSH_MONITOR_UPDATE_DIR = "/opt/scripts/update"
|
||||||
SSH_MONITOR_REPO_NAME = "ssh-monitor"
|
SSH_MONITOR_REPO_NAME = "ssh-monitor"
|
||||||
SSH_MONITOR_UPDATE_REPO_URL = "https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
|
SSH_MONITOR_UPDATE_REPO_URL = "https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
|
||||||
SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor"
|
SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor"
|
||||||
SSH_MONITOR_VERSION_CMD = (
|
SSH_MONITOR_VERSION_CMD = (
|
||||||
f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null "
|
f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null "
|
||||||
@@ -435,7 +433,7 @@ def _ssh_monitor_update_invoke_command(
|
|||||||
safe_repo = _shell_single_quote(repo_url.strip())
|
safe_repo = _shell_single_quote(repo_url.strip())
|
||||||
safe_branch = _shell_single_quote((git_branch or "main").strip() or "main")
|
safe_branch = _shell_single_quote((git_branch or "main").strip() or "main")
|
||||||
preflight = _ssh_monitor_preflight_git_remote(repo_url)
|
preflight = _ssh_monitor_preflight_git_remote(repo_url)
|
||||||
return f"{preflight}; UPDATE_VIA_SAC=1 REPO_URL={safe_repo} GIT_BRANCH={safe_branch} {SSH_MONITOR_UPDATE_SCRIPT}"
|
return f"{preflight}; 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:
|
def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -> str:
|
||||||
@@ -451,16 +449,7 @@ def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -
|
|||||||
f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && '
|
f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && '
|
||||||
f'([ -d "$SCRIPT_NAME" ] || git clone -b "$GIT_BRANCH" "$REPO_URL" "$SCRIPT_NAME") && '
|
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'cp "$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" "$INSTALL" && '
|
||||||
f'chmod 750 "$INSTALL" && UPDATE_VIA_SAC=1 REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL" --deploy'
|
f'chmod 750 "$INSTALL" && REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL"'
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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; "
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -508,7 +497,7 @@ def run_ssh_monitor_update(
|
|||||||
target=target,
|
target=target,
|
||||||
user=user,
|
user=user,
|
||||||
password=password,
|
password=password,
|
||||||
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + bootstrap_cmd,
|
remote_cmd=bootstrap_cmd,
|
||||||
command_timeout_sec=900,
|
command_timeout_sec=900,
|
||||||
need_root=True,
|
need_root=True,
|
||||||
login_shell=False,
|
login_shell=False,
|
||||||
@@ -519,7 +508,7 @@ def run_ssh_monitor_update(
|
|||||||
target=target,
|
target=target,
|
||||||
user=user,
|
user=user,
|
||||||
password=password,
|
password=password,
|
||||||
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + update_cmd,
|
remote_cmd=update_cmd,
|
||||||
command_timeout_sec=900,
|
command_timeout_sec=900,
|
||||||
need_root=True,
|
need_root=True,
|
||||||
login_shell=False,
|
login_shell=False,
|
||||||
@@ -560,29 +549,3 @@ def run_ssh_monitor_update(
|
|||||||
exit_code=updated.exit_code,
|
exit_code=updated.exit_code,
|
||||||
)
|
)
|
||||||
return updated
|
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)
|
|
||||||
|
|||||||
@@ -564,11 +564,9 @@ def _format_rdg_html(event: Event) -> str:
|
|||||||
err = _detail(details, "gateway_error_code", "error_code", default="")
|
err = _detail(details, "gateway_error_code", "error_code", default="")
|
||||||
if err != "-":
|
if err != "-":
|
||||||
msg += _line("⚠️", "Код ошибки", html_escape(err))
|
msg += _line("⚠️", "Код ошибки", html_escape(err))
|
||||||
from app.services.session_duration import extract_session_duration_sec, format_session_duration
|
dur = _detail(details, "session_duration_sec", default="")
|
||||||
|
if dur not in ("-", "0", ""):
|
||||||
dur_sec = extract_session_duration_sec(details if isinstance(details, dict) else None)
|
msg += _line("⏱️", "Длительность", f"{html_escape(dur)} с")
|
||||||
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))
|
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||||
win_id = _detail(details, "event_id_windows", default="")
|
win_id = _detail(details, "event_id_windows", default="")
|
||||||
if win_id != "-":
|
if win_id != "-":
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Effective Windows domain admin credentials (host → DB → env)."""
|
"""Effective Windows domain admin credentials (DB overrides env)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,7 +7,6 @@ from dataclasses import dataclass
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models.host import Host
|
|
||||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
|||||||
class WinAdminConfig:
|
class WinAdminConfig:
|
||||||
user: str
|
user: str
|
||||||
password: str
|
password: str
|
||||||
source: str # host | env | db
|
source: str # env | db
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
@@ -93,17 +92,3 @@ def upsert_win_admin_settings(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return get_effective_win_admin_config(db)
|
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
|
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -487,7 +487,7 @@ def run_winrm_rdp_monitor_update(
|
|||||||
)
|
)
|
||||||
|
|
||||||
effective_repo = (
|
effective_repo = (
|
||||||
repo_url or "https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
|
repo_url or "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
|
||||||
).strip()
|
).strip()
|
||||||
effective_branch = (git_branch or "main").strip() or "main"
|
effective_branch = (git_branch or "main").strip() or "main"
|
||||||
if not effective_repo:
|
if not effective_repo:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.5.17"
|
APP_VERSION = "0.5.0"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from app.services.agent_version import reference_agent_versions_by_product
|
|||||||
|
|
||||||
|
|
||||||
def test_normalize_git_repo_url():
|
def test_normalize_git_repo_url():
|
||||||
assert normalize_git_repo_url("git.papatramp.ru/PapaTramp/ssh-monitor").endswith(
|
assert normalize_git_repo_url("git.kalinamall.ru/PapaTramp/ssh-monitor").endswith(
|
||||||
"ssh-monitor.git"
|
"ssh-monitor.git"
|
||||||
)
|
)
|
||||||
assert normalize_git_repo_url("https://example.com/repo.git") == "https://example.com/repo.git"
|
assert normalize_git_repo_url("https://example.com/repo.git") == "https://example.com/repo.git"
|
||||||
|
|||||||
@@ -247,30 +247,6 @@ def test_clear_stale_running_remote_actions(db_session):
|
|||||||
assert host.remote_action["ok"] is False
|
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):
|
def test_cancel_host_remote_job_api(jwt_headers, client, db_session):
|
||||||
host = Host(
|
host = Host(
|
||||||
hostname="cancel-me",
|
hostname="cancel-me",
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -8,7 +8,6 @@ from app.services.host_sessions import (
|
|||||||
event_session_terminated,
|
event_session_terminated,
|
||||||
event_supports_session_terminate,
|
event_supports_session_terminate,
|
||||||
filter_logind_session_rows,
|
filter_logind_session_rows,
|
||||||
filter_windows_sessions_for_user,
|
|
||||||
mark_event_session_terminated,
|
mark_event_session_terminated,
|
||||||
parse_loginctl_sessions,
|
parse_loginctl_sessions,
|
||||||
parse_loginctl_sessions_json,
|
parse_loginctl_sessions_json,
|
||||||
@@ -74,32 +73,6 @@ def test_parse_qwinsta_sessions_filters_user():
|
|||||||
assert filtered[0].session_id == "2"
|
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():
|
def test_event_supports_session_terminate_types():
|
||||||
class HostStub:
|
class HostStub:
|
||||||
os_family = "linux"
|
os_family = "linux"
|
||||||
@@ -121,48 +94,6 @@ def test_event_login_user_without_orm_actor_user_attr():
|
|||||||
assert _event_login_user(event) == "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):
|
def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch):
|
||||||
host = SimpleNamespace(
|
host = SimpleNamespace(
|
||||||
os_family="windows",
|
os_family="windows",
|
||||||
|
|||||||
@@ -133,48 +133,3 @@ def test_ingest_daily_report_calls_notify_daily_report(client, auth_headers):
|
|||||||
mock_daily.assert_called_once()
|
mock_daily.assert_called_once()
|
||||||
mock_event.assert_not_called()
|
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()
|
|
||||||
|
|
||||||
|
|||||||
@@ -174,51 +174,3 @@ def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch)
|
|||||||
assert job["target"] == "ubabuba"
|
assert job["target"] == "ubabuba"
|
||||||
if "product_version" in job:
|
if "product_version" in job:
|
||||||
assert job["product_version"] is None or isinstance(job["product_version"], str)
|
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()
|
|
||||||
|
|||||||
@@ -266,113 +266,3 @@ 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 close_workstation_session_for_rdg_end(db_session, end) is None
|
||||||
assert event_session_terminated(login, db=db_session) is False
|
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
|
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"""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"
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""SSH connect service tests (paramiko mocked via sys.modules)."""
|
"""SSH connect service tests (paramiko mocked via sys.modules)."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -323,7 +323,7 @@ def test_run_ssh_monitor_update_runs_script(monkeypatch):
|
|||||||
target="ubabuba",
|
target="ubabuba",
|
||||||
user="root",
|
user="root",
|
||||||
password="pw",
|
password="pw",
|
||||||
repo_url="https://git.papatramp.ru/PapaTramp/ssh-monitor.git",
|
repo_url="https://git.kalinamall.ru/PapaTramp/ssh-monitor.git",
|
||||||
)
|
)
|
||||||
assert result.ok is True
|
assert result.ok is True
|
||||||
assert result.agent_version == "2.1.0-SAC"
|
assert result.agent_version == "2.1.0-SAC"
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ DATABASE_URL=postgresql+psycopg2://sac:CHANGE_ME_POSTGRES_PASSWORD@127.0.0.1:543
|
|||||||
# SQLAlchemy pool (uvicorn workers × concurrent ingest). Было по умолчанию 5+10 — мало для штурма 09:00.
|
# SQLAlchemy pool (uvicorn workers × concurrent ingest). Было по умолчанию 5+10 — мало для штурма 09:00.
|
||||||
SAC_DB_POOL_SIZE=15
|
SAC_DB_POOL_SIZE=15
|
||||||
SAC_DB_MAX_OVERFLOW=25
|
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
|
SAC_PUBLIC_URL=https://sac.kalinamall.ru
|
||||||
# Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS).
|
# Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS).
|
||||||
@@ -130,7 +128,6 @@ SAC_MOBILE_REFRESH_EXPIRE_DAYS=90
|
|||||||
CORS_ORIGINS=https://sac.kalinamall.ru
|
CORS_ORIGINS=https://sac.kalinamall.ru
|
||||||
|
|
||||||
# SSH: verify host keys (RejectPolicy). Add keys to file before remote actions.
|
# 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_KNOWN_HOSTS_FILE=/opt/security-alert-center/config/ssh_known_hosts
|
||||||
# SAC_SSH_AUTO_ADD_HOST_KEY=false
|
# SAC_SSH_AUTO_ADD_HOST_KEY=false
|
||||||
|
|
||||||
|
|||||||
@@ -44,19 +44,6 @@ server {
|
|||||||
proxy_read_timeout 960s;
|
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 / {
|
location / {
|
||||||
proxy_pass http://sac_api;
|
proxy_pass http://sac_api;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -69,19 +69,6 @@ server {
|
|||||||
proxy_read_timeout 960s;
|
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 / {
|
location / {
|
||||||
proxy_pass http://sac_api;
|
proxy_pass http://sac_api;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
+9
-27
@@ -56,16 +56,12 @@ fi
|
|||||||
log "pip install -r requirements.txt"
|
log "pip install -r requirements.txt"
|
||||||
sudo -u "${APP_USER}" "${VENV}/bin/pip" install -q -r "${APP_ROOT}/backend/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"
|
log "alembic upgrade head"
|
||||||
sudo -u "${APP_USER}" bash -c "
|
sudo -u "${APP_USER}" bash -c "
|
||||||
export SAC_CONFIG_FILE='${CONFIG_FILE}'
|
set -a
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source '${CONFIG_FILE}'
|
||||||
|
set +a
|
||||||
cd '${APP_ROOT}/backend' && '${VENV}/bin/alembic' upgrade head
|
cd '${APP_ROOT}/backend' && '${VENV}/bin/alembic' upgrade head
|
||||||
"
|
"
|
||||||
|
|
||||||
@@ -93,16 +89,6 @@ if [ -f "${APP_ROOT}/deploy/systemd/${SERVICE_NAME}.service" ]; then
|
|||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
fi
|
fi
|
||||||
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)"
|
log "Проверка DATABASE_URL (как у uvicorn через SAC_CONFIG_FILE)"
|
||||||
sudo -u "${APP_USER}" bash -c "
|
sudo -u "${APP_USER}" bash -c "
|
||||||
@@ -121,7 +107,10 @@ print('db: OK')
|
|||||||
log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)"
|
log "systemctl restart ${SERVICE_NAME} (краткий 502 в UI возможен ~10 с)"
|
||||||
log "Сброс зависших remote_action (running без worker после restart)"
|
log "Сброс зависших remote_action (running без worker после restart)"
|
||||||
sudo -u "${APP_USER}" bash -c "
|
sudo -u "${APP_USER}" bash -c "
|
||||||
export SAC_CONFIG_FILE='${CONFIG_FILE}'
|
set -a
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source '${CONFIG_FILE}'
|
||||||
|
set +a
|
||||||
cd '${APP_ROOT}/backend'
|
cd '${APP_ROOT}/backend'
|
||||||
'${VENV}/bin/python' -c \"
|
'${VENV}/bin/python' -c \"
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
@@ -136,14 +125,7 @@ finally:
|
|||||||
\"
|
\"
|
||||||
"
|
"
|
||||||
systemctl restart "${SERVICE_NAME}"
|
systemctl restart "${SERVICE_NAME}"
|
||||||
sleep 2
|
systemctl is-active --quiet "${SERVICE_NAME}" || die "${SERVICE_NAME} не active"
|
||||||
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
|
HEALTH_OK=0
|
||||||
for _ in 1 2 3 4 5 6; do
|
for _ in 1 2 3 4 5 6; do
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Uvicorn launcher: SAC_UVICORN_WORKERS из sac-api.env (без systemd EnvironmentFile).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
APP_ROOT="${SAC_APP_ROOT:-/opt/security-alert-center}"
|
|
||||||
CONFIG_FILE="${SAC_CONFIG_FILE:-${APP_ROOT}/config/sac-api.env}"
|
|
||||||
VENV="${APP_ROOT}/backend/.venv"
|
|
||||||
HOST="127.0.0.1"
|
|
||||||
PORT="8000"
|
|
||||||
WORKERS=4
|
|
||||||
|
|
||||||
_read_env_int() {
|
|
||||||
local key="$1" default="$2" line val
|
|
||||||
[ -f "$CONFIG_FILE" ] || {
|
|
||||||
printf '%s\n' "$default"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
line="$(grep -E "^[[:space:]]*${key}=" "$CONFIG_FILE" 2>/dev/null | tail -1)" || {
|
|
||||||
printf '%s\n' "$default"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
val="${line#*=}"
|
|
||||||
val="${val#"${val%%[![:space:]]*}"}"
|
|
||||||
val="${val%"${val##*[![:space:]]}"}"
|
|
||||||
val="${val#\"}"
|
|
||||||
val="${val%\"}"
|
|
||||||
val="${val#\'}"
|
|
||||||
val="${val%\'}"
|
|
||||||
if [[ "$val" =~ ^[0-9]+$ ]] && [ "$val" -ge 1 ]; then
|
|
||||||
printf '%s\n' "$val"
|
|
||||||
else
|
|
||||||
printf '%s\n' "$default"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
WORKERS="$(_read_env_int SAC_UVICORN_WORKERS 4)"
|
|
||||||
|
|
||||||
exec "${VENV}/bin/uvicorn" app.main:app \
|
|
||||||
--host "$HOST" \
|
|
||||||
--port "$PORT" \
|
|
||||||
--workers "$WORKERS" \
|
|
||||||
--timeout-graceful-shutdown 30
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Security Alert Center API (FastAPI)
|
Description=Security Alert Center API (FastAPI)
|
||||||
Documentation=https://git.papatramp.ru/PapaTramp/security-alert-center
|
Documentation=https://git.kalinamall.ru/PapaTramp/security-alert-center
|
||||||
After=network-online.target postgresql.service
|
After=network-online.target postgresql.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
Requires=postgresql.service
|
Requires=postgresql.service
|
||||||
@@ -13,7 +13,7 @@ WorkingDirectory=/opt/security-alert-center/backend
|
|||||||
# Конфиг читает само приложение (pydantic), не systemd — иначе ломаются пароли с #, $ и т.д.
|
# Конфиг читает само приложение (pydantic), не systemd — иначе ломаются пароли с #, $ и т.д.
|
||||||
Environment=SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env
|
Environment=SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env
|
||||||
Environment=PYTHONPATH=/opt/security-alert-center/backend
|
Environment=PYTHONPATH=/opt/security-alert-center/backend
|
||||||
ExecStart=/usr/bin/bash /opt/security-alert-center/deploy/systemd/sac-api-start.sh
|
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
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
TimeoutStopSec=20
|
TimeoutStopSec=20
|
||||||
|
|||||||
@@ -183,13 +183,11 @@ sequenceDiagram
|
|||||||
|
|
||||||
## 5. П.2C — Доступ SAC к хостам
|
## 5. П.2C — Доступ SAC к хостам
|
||||||
|
|
||||||
### 5.1. Windows — WinRM / учётки
|
### 5.1. Windows — WinRM
|
||||||
|
|
||||||
- **Глобальный доменный admin** в **Настройки → Windows**: `DOMAIN\user` + password (encrypted). Аналогично **Linux admin** для SSH.
|
- **Один доменный admin** в **Настройки → Управление хостами → Windows**: `DOMAIN\user` + password (encrypted).
|
||||||
- **Override на карточке хоста** (**Хосты → WinRM/SSH / доступ к хосту**): `COMPUTER\user` / `.\Administrator` + password (encrypted в `hosts.mgmt_*`). Если пусто — берутся глобальные.
|
- Override на карточке хоста — опционально позже.
|
||||||
- Effective creds: `get_effective_win_admin_for_host` / `get_effective_linux_admin_for_host` (source: `host` | `db` | `env`).
|
- Используется для fallback-обновления и (при необходимости) remote ops; для qwinsta/logoff MVP — **через агента** с теми же creds, переданными в command poll (TLS + API key, не пишутся на диск агента).
|
||||||
- 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
|
### 5.2. Linux — bootstrap password → SSH key → удалить password
|
||||||
|
|
||||||
@@ -268,7 +266,7 @@ Authorization: Bearer sac_xxx
|
|||||||
| POST | `/api/v1/events/{id}/actions/logoff` | logoff `{ "session_id": 5 }` |
|
| POST | `/api/v1/events/{id}/actions/logoff` | logoff `{ "session_id": 5 }` |
|
||||||
| GET | `/api/v1/events/{id}/actions/{cmd_id}` | Статус / результат |
|
| GET | `/api/v1/events/{id}/actions/{cmd_id}` | Статус / результат |
|
||||||
| PATCH | `/api/v1/hosts/{id}/config` | Desired config |
|
| PATCH | `/api/v1/hosts/{id}/config` | Desired config |
|
||||||
| GET/PUT | `/api/v1/hosts/{id}/access` | Per-host WinRM/SSH override (mgmt_user/password) |
|
| PATCH | `/api/v1/hosts/{id}/access` | Bootstrap / WinRM creds |
|
||||||
| GET/PATCH | `/api/v1/settings/agent-updates` | Режим A/B, версии, источники |
|
| GET/PATCH | `/api/v1/settings/agent-updates` | Режим A/B, версии, источники |
|
||||||
| GET/PATCH | `/api/v1/settings/host-management` | Доменный admin Windows |
|
| GET/PATCH | `/api/v1/settings/host-management` | Доменный admin Windows |
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
|
|||||||
| 4624 успех | `rdp.login.success` | info |
|
| 4624 успех | `rdp.login.success` | info |
|
||||||
| 4625 неудача | `rdp.login.failed` | warning |
|
| 4625 неудача | `rdp.login.failed` | warning |
|
||||||
| RCM **20506** Shadow Control started | `rdp.shadow.control.started` | **warning** |
|
| RCM **20506** Shadow Control started | `rdp.shadow.control.started` | **warning** |
|
||||||
| RCM **20506** Shadow Control started | `rdp.shadow.control.started` | **warning** |
|
|
||||||
| RCM **20507** Shadow Control stopped | `rdp.shadow.control.stopped` | **warning** |
|
| RCM **20507** Shadow Control stopped | `rdp.shadow.control.stopped` | **warning** |
|
||||||
| RCM **20510** Shadow Control permission | `rdp.shadow.control.permission` | **warning** |
|
| RCM **20510** Shadow Control permission | `rdp.shadow.control.permission` | **warning** |
|
||||||
| WinRM **91** inbound shell (Enter-PSSession) | `winrm.session.started` | **warning** |
|
| WinRM **91** inbound shell (Enter-PSSession) | `winrm.session.started` | **warning** |
|
||||||
|
|||||||
+1
-1
@@ -115,7 +115,7 @@ sudo /opt/sac-deploy.sh
|
|||||||
|
|
||||||
Краткий **502 Bad Gateway** в UI (~10 с) возможен при перезапуске `sac-api` — список хостов автоматически повторяет запрос; после деплоя обновите страницу.
|
Краткий **502 Bad Gateway** в UI (~10 с) возможен при перезапуске `sac-api` — список хостов автоматически повторяет запрос; после деплоя обновите страницу.
|
||||||
|
|
||||||
**Важно в `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`).
|
**Важно в `sac-api.env`:** `SAC_PUBLIC_URL=https://sac.kalinamall.ru` — нужен для WinRM-обновления RDP (клиент скачивает zip с SAC). API: **2 worker** uvicorn (`deploy/systemd/sac-api.service`).
|
||||||
|
|
||||||
Установка скрипта (один раз): `sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh && sudo chmod 755 /opt/sac-deploy.sh`
|
Установка скрипта (один раз): `sudo cp /opt/security-alert-center/deploy/sac-deploy.sh /opt/sac-deploy.sh && sudo chmod 755 /opt/sac-deploy.sh`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.papatramp.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
|
"$id": "https://git.kalinamall.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
|
||||||
"title": "Security Alert Center Event v1",
|
"title": "Security Alert Center Event v1",
|
||||||
"description": "Каноническое событие от ssh-monitor или RDP-login-monitor",
|
"description": "Каноническое событие от ssh-monitor или RDP-login-monitor",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -94,7 +94,6 @@
|
|||||||
"session.logind.new",
|
"session.logind.new",
|
||||||
"rdp.login.success",
|
"rdp.login.success",
|
||||||
"rdp.login.failed",
|
"rdp.login.failed",
|
||||||
"rdp.session.logoff",
|
|
||||||
"rdp.shadow.control.started",
|
"rdp.shadow.control.started",
|
||||||
"rdp.shadow.control.stopped",
|
"rdp.shadow.control.stopped",
|
||||||
"rdp.shadow.control.permission",
|
"rdp.shadow.control.permission",
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# Backlog: ingest и массовое обновление агентов
|
|
||||||
|
|
||||||
**Статус:** ToDo (не в текущем релизе).
|
|
||||||
**Контекст:** массовый SSH-update через SAC (10+ хостов) + `sac-deploy` в одно окно → часть POST в ingest «теряется» с точки зрения агента (`SAC POST HTTP :`), flood в Telegram (fallback + watchdog).
|
|
||||||
**Связано:** [runbook-ops.md](runbook-ops.md), [agent-integration.md](agent-integration.md), ssh-monitor [security-roadmap.ru.md](https://git.papatramp.ru/PapaTramp/ssh-monitor/src/branch/main/docs/security-roadmap.ru.md).
|
|
||||||
|
|
||||||
Увеличение `SAC_DB_POOL_SIZE` / defer daily в **0.5.0** лечит штурм суточных отчётов и пул БД; **не** снимает узкие места ниже при одновременном restart многих агентов.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Операционно (сразу, без кода)
|
|
||||||
|
|
||||||
- [ ] **Не совмещать** `sudo /opt/sac-deploy.sh` и массовое «Обновить ssh-monitor (SSH)» по многим хостам — сначала deploy SAC, потом агенты (или наоборот).
|
|
||||||
- [ ] Обновлять Linux-хосты **пачками по 2–3**, не все подряд.
|
|
||||||
- [ ] На зрелых хостах перевести **`UseSAC=exclusive`** (нет дубля в Telegram с агента при fallback); watchdog по-прежнему шлёт в Telegram сам.
|
|
||||||
- [ ] Перед первым SSH-update с SAC: **`ssh-keyscan`** в `config/ssh_known_hosts` (см. runbook).
|
|
||||||
- [ ] В `sac-api.env`: только `KEY=value` на строку, комментарии отдельной строкой с `#`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SAC (backend / deploy)
|
|
||||||
|
|
||||||
- [x] **Defer** `notify_lifecycle` и `notify_auth_login` в background (как `report.daily.*` → `schedule_notify_daily_report`), чтобы ingest не ждал Telegram API — **0.5.5**
|
|
||||||
- [x] **Uvicorn workers:** 4 по умолчанию или `SAC_UVICORN_WORKERS` в `sac-api.env` / `sac-api-start.sh` — **0.5.5**
|
|
||||||
- [x] **nginx:** отдельный `location` для `POST /api/v1/events` с увеличенным `proxy_read_timeout` — **0.5.5**
|
|
||||||
- [ ] Опционально: метрики/лог длительности ingest и очереди при burst.
|
|
||||||
- [x] UI: предупреждение при массовом update («N хостов — рекомендуется пачками») — **0.5.5**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ssh-monitor (агент)
|
|
||||||
|
|
||||||
- [x] При **shutdown** / `SIGTERM` не увеличивать `sac-fail.count` — **2.3.2-SAC**
|
|
||||||
- [x] После успешного heartbeat сбрасывать fail counter (успешный POST ingest) — уже было
|
|
||||||
- [x] Watchdog: не слать Telegram при штатном restart во время SAC-update (state file `/var/lib/ssh-monitor/agent-update-in-progress` от updater) — **2.2.1-SAC**
|
|
||||||
- [x] Sudo bootstrap/update через SAC: не слать Telegram (`ssh_monitor_sudo_is_sac_maintenance`, state-file раньше) — **2.2.3-SAC**
|
|
||||||
- [x] SAC: suppress `privilege.sudo.command` при `agent_update_state=running` / maintenance command — **0.5.3**
|
|
||||||
- [x] Документировать рекомендуемый `SAC_TIMEOUT_SEC` при тяжёлом ingest — **2.3.2-SAC** (`docs/sac-ingest.ru.md`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Принято / сделано
|
|
||||||
|
|
||||||
- [x] Pool БД 15+25, defer daily push — SAC **0.5.0**
|
|
||||||
- [x] `sac-deploy.sh` без `source` конфига, preflight pydantic — SAC `fa1bd41`
|
|
||||||
- [x] Watchdog: строка `🖥️ Сервер:` в Telegram — ssh-monitor **2.1.9-SAC**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*После реализации пунктов SAC — bump `APP_VERSION` и runbook.*
|
|
||||||
@@ -24,7 +24,7 @@ Docker удобен для локальной отладки или изолир
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install -y git
|
sudo apt install -y git
|
||||||
sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||||
sudo chown -R "$USER:$USER" /opt/security-alert-center
|
sudo chown -R "$USER:$USER" /opt/security-alert-center
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ nginx -v
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /opt
|
sudo mkdir -p /opt
|
||||||
sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
```bash
|
```bash
|
||||||
# см. полный чеклист в native-руководстве
|
# см. полный чеклист в native-руководстве
|
||||||
sudo apt update && sudo apt install -y git postgresql nginx python3.12 python3.12-venv
|
sudo apt update && sudo apt install -y git postgresql nginx python3.12 python3.12-venv
|
||||||
sudo git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
sudo git clone https://git.kalinamall.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||||
# … PostgreSQL, venv, config/sac-api.env, systemd, nginx
|
# … PostgreSQL, venv, config/sac-api.env, systemd, nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ sudo /opt/sac-deploy.sh
|
|||||||
| Параметр | Значение |
|
| Параметр | Значение |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| Хост | `ubabuba` / `10.10.36.9` |
|
| Хост | `ubabuba` / `10.10.36.9` |
|
||||||
| Репозиторий | `git.papatramp.ru/PapaTramp/ssh-monitor` (`main`, есть `sac-client.sh`) |
|
| Репозиторий | `git.kalinamall.ru/PapaTramp/ssh-monitor` (`main`, есть `sac-client.sh`) |
|
||||||
| `UseSAC` | пилот **exclusive** — см. [pilot-2.1-exclusive.md](pilot-2.1-exclusive.md) |
|
| `UseSAC` | пилот **exclusive** — см. [pilot-2.1-exclusive.md](pilot-2.1-exclusive.md) |
|
||||||
| Сервис | `ssh-monitor.service` — **active** |
|
| Сервис | `ssh-monitor.service` — **active** |
|
||||||
| `--check-sac` | OK (health + ingest `agent.test` 202) |
|
| `--check-sac` | OK (health + ingest `agent.test` 202) |
|
||||||
|
|||||||
+3
-3
@@ -66,7 +66,7 @@
|
|||||||
|
|
||||||
**Цель:** нативный Android-клиент и push по тем же правилам оповещений, что Telegram/email/webhook.
|
**Цель:** нативный Android-клиент и push по тем же правилам оповещений, что Telegram/email/webhook.
|
||||||
|
|
||||||
Репозиторий клиента: [seaca](https://git.papatramp.ru/PapaTramp/seaca) (Kotlin, Jetpack Compose, FCM).
|
Репозиторий клиента: [seaca](https://git.kalinamall.ru/PapaTramp/seaca) (Kotlin, Jetpack Compose, FCM).
|
||||||
|
|
||||||
**Сервер SAC:**
|
**Сервер SAC:**
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
**Не в мобильном клиенте:** пользователи SAC, каналы Telegram/SMTP/webhook, админ-настройки.
|
**Не в мобильном клиенте:** пользователи SAC, каналы Telegram/SMTP/webhook, админ-настройки.
|
||||||
|
|
||||||
Подробный план клиента — [seaca/docs/ROADMAP.md](https://git.papatramp.ru/PapaTramp/seaca/src/branch/main/docs/ROADMAP.md).
|
Подробный план клиента — [seaca/docs/ROADMAP.md](https://git.kalinamall.ru/PapaTramp/seaca/src/branch/main/docs/ROADMAP.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
| v0.1 | — (не требуется) | — |
|
| v0.1 | — (не требуется) | — |
|
||||||
| v0.2 | TBD (тег с UseSAC) | TBD |
|
| v0.2 | TBD (тег с UseSAC) | TBD |
|
||||||
| v0.3+ | совместимость schema 1.0 | совместимость schema 1.0 |
|
| v0.3+ | совместимость schema 1.0 | совместимость schema 1.0 |
|
||||||
| v0.6 | — | — (мобильный клиент [seaca](https://git.papatramp.ru/PapaTramp/seaca), не агент) |
|
| v0.6 | — | — (мобильный клиент [seaca](https://git.kalinamall.ru/PapaTramp/seaca), не агент) |
|
||||||
|
|
||||||
При breaking change схемы — `schema_version: 1.1` с поддержкой 1.0 на ingest.
|
При breaking change схемы — `schema_version: 1.1` с поддержкой 1.0 на ingest.
|
||||||
|
|
||||||
|
|||||||
@@ -18,32 +18,6 @@ curl -sS https://sac.kalinamall.ru/health | jq .
|
|||||||
|
|
||||||
Ожидается `status: ok`, `database: ok`. При устаревших heartbeat агентов — `status: degraded`, поле `hosts_stale` > 0.
|
Ожидается `status: ok`, `database: ok`. При устаревших heartbeat агентов — `status: degraded`, поле `hosts_stale` > 0.
|
||||||
|
|
||||||
### `sac-api.env`: формат строк
|
|
||||||
|
|
||||||
Файл читается **pydantic** (`SAC_CONFIG_FILE`), не как произвольный bash-скрипт.
|
|
||||||
|
|
||||||
- Одна переменная — одна строка: `SAC_SSH_AUTO_ADD_HOST_KEY=false`
|
|
||||||
- Комментарии — **отдельной** строкой с `#` в начале
|
|
||||||
- **Нельзя** inline после значения: `false ← так и оставляем` — deploy упадёт на `alembic` с `bool_parsing`
|
|
||||||
|
|
||||||
Проверка без деплоя:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo -u sac bash -c 'export SAC_CONFIG_FILE=/opt/security-alert-center/config/sac-api.env; cd /opt/security-alert-center/backend && .venv/bin/python -c "from app.config import get_settings; get_settings(); print(\"OK\")"'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linux SSH update: `known_hosts`
|
|
||||||
|
|
||||||
Перед «Обновить ssh-monitor (SSH)» ключ хоста должен быть в файле (по умолчанию `config/ssh_known_hosts`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh-keyscan -H 10.10.7.2 | sudo tee -a /opt/security-alert-center/config/ssh_known_hosts
|
|
||||||
sudo chown sac:sac /opt/security-alert-center/config/ssh_known_hosts
|
|
||||||
sudo chmod 600 /opt/security-alert-center/config/ssh_known_hosts
|
|
||||||
```
|
|
||||||
|
|
||||||
Иначе SAC: `Server '…' not found in known_hosts`. `SAC_SSH_AUTO_ADD_HOST_KEY=true` для prod не рекомендуется.
|
|
||||||
|
|
||||||
## Мобильные устройства (Seaca)
|
## Мобильные устройства (Seaca)
|
||||||
|
|
||||||
См. [seaca-mobile.md](seaca-mobile.md) и [seaca-fcm.md](seaca-fcm.md).
|
См. [seaca-mobile.md](seaca-mobile.md) и [seaca-fcm.md](seaca-fcm.md).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Seaca — мобильный клиент SAC
|
# Seaca — мобильный клиент SAC
|
||||||
|
|
||||||
Репозиторий приложения: [seaca](https://git.papatramp.ru/PapaTramp/seaca).
|
Репозиторий приложения: [seaca](https://git.kalinamall.ru/PapaTramp/seaca).
|
||||||
Требуется SAC **≥ 0.9.0** с применённой миграцией `014`.
|
Требуется 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.kalinamall.ru` | Web UI | yes (`git-sac-allowed`) |
|
||||||
| `sac-api.kalinamall.ru` | Seaca, `/api/v1/mobile/*` | no (enroll + JWT) |
|
| `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.papatramp.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.kalinamall.ru/PapaTramp/reverse-proxy/src/branch/main/docs/sac-access.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
| Имя в workspace | Путь (пример Windows) | Remote |
|
| Имя в workspace | Путь (пример Windows) | Remote |
|
||||||
|-----------------|----------------------|--------|
|
|-----------------|----------------------|--------|
|
||||||
| `ssh-monitor` | `D:\Soft\Git\ssh-monitor` | git.papatramp.ru/PapaTramp/ssh-monitor |
|
| `ssh-monitor` | `D:\Soft\Git\ssh-monitor` | git.kalinamall.ru/PapaTramp/ssh-monitor |
|
||||||
| `rdp-monitor` | `D:\Soft\Git\RDP-login-monitor` | git.papatramp.ru/PapaTramp/RDP-login-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.papatramp.ru/PapaTramp/security-alert-center |
|
| `security-alert-center` | `D:\Soft\Git\security-alert-center` | git.kalinamall.ru/PapaTramp/security-alert-center |
|
||||||
|
|
||||||
Пути подставьте свои.
|
Пути подставьте свои.
|
||||||
|
|
||||||
@@ -65,9 +65,9 @@ git push kalinamall main
|
|||||||
|
|
||||||
| Проект | URL |
|
| Проект | URL |
|
||||||
|--------|-----|
|
|--------|-----|
|
||||||
| security-alert-center | `https://git.papatramp.ru/PapaTramp/security-alert-center.git` |
|
| security-alert-center | `https://git.kalinamall.ru/PapaTramp/security-alert-center.git` |
|
||||||
| ssh-monitor | `https://git.papatramp.ru/PapaTramp/ssh-monitor.git` |
|
| ssh-monitor | `https://git.kalinamall.ru/PapaTramp/ssh-monitor.git` |
|
||||||
| RDP-login-monitor | `https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git` |
|
| RDP-login-monitor | `https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-29
@@ -192,7 +192,6 @@ export interface EventSummary {
|
|||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
actor_user?: string | null;
|
actor_user?: string | null;
|
||||||
session_duration_sec?: number | null;
|
|
||||||
rdg_flap?: boolean;
|
rdg_flap?: boolean;
|
||||||
rdg_flap_pair_event_id?: number | null;
|
rdg_flap_pair_event_id?: number | null;
|
||||||
rdg_flap_qwinsta_event_id?: number | null;
|
rdg_flap_qwinsta_event_id?: number | null;
|
||||||
@@ -532,31 +531,6 @@ 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 {
|
export interface HostSshActionResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -639,9 +613,7 @@ export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteA
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
|
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
|
||||||
return apiFetch<HostRemoteActionJobStatus>(
|
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
|
||||||
`/api/v1/hosts/${hostId}/actions/remote-job?_ts=${Date.now()}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostSessionItem {
|
export interface HostSessionItem {
|
||||||
|
|||||||
@@ -1,60 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="open" class="host-action-log-dock" aria-live="polite">
|
<div v-if="open" class="host-action-log-dock" aria-live="polite">
|
||||||
<div
|
<div class="host-action-log-panel" role="dialog" :aria-label="title">
|
||||||
class="host-action-log-panel"
|
|
||||||
:class="{ 'host-action-log-panel-done': !isLoading && ok === true, 'host-action-log-panel-fail': !isLoading && ok === false }"
|
|
||||||
role="dialog"
|
|
||||||
:aria-label="displayTitle"
|
|
||||||
>
|
|
||||||
<div class="host-action-log-header">
|
<div class="host-action-log-header">
|
||||||
<h3>
|
<h3>{{ title }}</h3>
|
||||||
<span v-if="!isLoading && ok === true" class="host-action-log-mark host-action-log-mark-ok" aria-hidden="true">✓</span>
|
|
||||||
<span v-else-if="!isLoading && ok === false" class="host-action-log-mark host-action-log-mark-fail" aria-hidden="true">✗</span>
|
|
||||||
{{ displayTitle }}
|
|
||||||
</h3>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="host-action-log-icon-btn"
|
class="host-action-log-icon-btn"
|
||||||
:title="isLoading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'"
|
:title="loading ? 'Свернуть — обновление продолжится на сервере' : 'Закрыть'"
|
||||||
:aria-label="isLoading ? 'Свернуть' : 'Закрыть'"
|
:aria-label="loading ? 'Свернуть' : 'Закрыть'"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="isLoading" class="host-action-log-status">
|
<p v-if="loading" class="host-action-log-status">
|
||||||
<span class="host-action-log-spinner" aria-hidden="true" />
|
<span class="host-action-log-spinner" aria-hidden="true" />
|
||||||
Выполняется на удалённом хосте… Можно запустить обновление других хостов.
|
Выполняется на удалённом хосте… Можно запустить обновление других хостов.
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p v-if="message && !loading" class="host-action-log-message" :class="messageClass">{{ message }}</p>
|
||||||
v-else-if="ok === true"
|
<p v-else-if="message && loading" class="muted host-action-log-sub host-action-log-message">{{ message }}</p>
|
||||||
class="host-action-log-message success host-action-log-result"
|
<pre v-if="output" class="host-action-log-output">{{ output }}</pre>
|
||||||
>
|
|
||||||
{{ displayMessage }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-else-if="ok === false"
|
|
||||||
class="host-action-log-message error host-action-log-result"
|
|
||||||
>
|
|
||||||
{{ displayMessage }}
|
|
||||||
</p>
|
|
||||||
<p v-else-if="displayMessage && isLoading" class="muted host-action-log-sub host-action-log-message">
|
|
||||||
{{ displayMessage }}
|
|
||||||
</p>
|
|
||||||
<p v-if="!isLoading && ok === true && autoCloseSec > 0" class="muted host-action-log-sub">
|
|
||||||
Окно закроется автоматически через {{ autoCloseSec }} с
|
|
||||||
</p>
|
|
||||||
<pre
|
|
||||||
v-show="logVisible"
|
|
||||||
ref="logPreRef"
|
|
||||||
class="host-action-log-output"
|
|
||||||
>{{ displayOutput }}</pre>
|
|
||||||
<div class="host-action-log-actions">
|
<div class="host-action-log-actions">
|
||||||
<button type="button" class="secondary" @click="emit('toggle-log')">
|
<button v-if="loading" type="button" class="secondary" @click="emit('close')">
|
||||||
{{ logVisible ? "Скрыть лог" : "Показать лог" }}
|
Свернуть
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="secondary" @click="emit('close')">
|
<button v-else type="button" class="secondary" @click="emit('close')">
|
||||||
{{ isLoading ? "Свернуть" : "Закрыть" }}
|
Закрыть
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,210 +33,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
|
import { computed } from "vue";
|
||||||
import { fetchHostRemoteJob, type HostRemoteActionJobStatus } from "../api";
|
|
||||||
|
|
||||||
const LOG_POLL_MS = 1500;
|
|
||||||
const AUTO_CLOSE_MS = 30_000;
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
hostId: number;
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
ok: boolean | null;
|
ok: boolean | null;
|
||||||
message: string;
|
message: string;
|
||||||
output: string;
|
output: string;
|
||||||
logPlaceholder: string;
|
|
||||||
logVisible: boolean;
|
|
||||||
sessionStartedAt: string;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: [];
|
close: [];
|
||||||
"toggle-log": [];
|
|
||||||
finished: [job: HostRemoteActionJobStatus];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const logPreRef = ref<HTMLElement | null>(null);
|
const messageClass = computed(() => {
|
||||||
const polledOutput = ref("");
|
if (props.loading || props.ok == null) return "muted";
|
||||||
const polledMessage = ref("");
|
return props.ok ? "success" : "error";
|
||||||
const isLoading = ref(true);
|
|
||||||
const ok = ref<boolean | null>(null);
|
|
||||||
const localTitle = ref("");
|
|
||||||
const localMessage = ref("");
|
|
||||||
const autoCloseSec = ref(0);
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let finishedEmitted = false;
|
|
||||||
let sawRunning = false;
|
|
||||||
|
|
||||||
function isTerminalJob(job: HostRemoteActionJobStatus): boolean {
|
|
||||||
const st = (job.status || "").toLowerCase();
|
|
||||||
return st === "success" || st === "failed";
|
|
||||||
}
|
|
||||||
|
|
||||||
function jobStartedAfterSession(job: HostRemoteActionJobStatus): boolean {
|
|
||||||
const started = job.started_at;
|
|
||||||
if (!started) return false;
|
|
||||||
const jobMs = Date.parse(started);
|
|
||||||
const sessionMs = Date.parse(props.sessionStartedAt);
|
|
||||||
if (Number.isNaN(jobMs) || Number.isNaN(sessionMs)) return false;
|
|
||||||
return jobMs >= sessionMs - 3000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isJobRunning(job: HostRemoteActionJobStatus): boolean {
|
|
||||||
if (job.active) return true;
|
|
||||||
const st = (job.status || "").toLowerCase();
|
|
||||||
if (st === "running") return true;
|
|
||||||
return (job.agent_update_state || "").toLowerCase() === "running";
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayTitle = computed(() => {
|
|
||||||
if (localTitle.value) return localTitle.value;
|
|
||||||
return props.title;
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayMessage = computed(() => {
|
|
||||||
if (localMessage.value) return localMessage.value;
|
|
||||||
if (polledMessage.value) return polledMessage.value;
|
|
||||||
return props.message;
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayOutput = computed(() => {
|
|
||||||
const text = (polledOutput.value || props.output).trim();
|
|
||||||
return text || props.logPlaceholder;
|
|
||||||
});
|
|
||||||
|
|
||||||
function stopPoll() {
|
|
||||||
if (pollTimer != null) {
|
|
||||||
clearInterval(pollTimer);
|
|
||||||
pollTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopCountdown() {
|
|
||||||
if (countdownTimer != null) {
|
|
||||||
clearInterval(countdownTimer);
|
|
||||||
countdownTimer = null;
|
|
||||||
}
|
|
||||||
autoCloseSec.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCountdown() {
|
|
||||||
stopCountdown();
|
|
||||||
autoCloseSec.value = Math.ceil(AUTO_CLOSE_MS / 1000);
|
|
||||||
countdownTimer = setInterval(() => {
|
|
||||||
if (autoCloseSec.value <= 1) {
|
|
||||||
stopCountdown();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
autoCloseSec.value -= 1;
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFinishedJob(job: HostRemoteActionJobStatus) {
|
|
||||||
isLoading.value = false;
|
|
||||||
ok.value = job.ok ?? job.status === "success";
|
|
||||||
if (job.title) localTitle.value = job.title;
|
|
||||||
if (job.message) localMessage.value = job.message;
|
|
||||||
const out = (job.output || job.stdout || "").trim();
|
|
||||||
if (out) polledOutput.value = out;
|
|
||||||
if (ok.value) startCountdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollJobOnce() {
|
|
||||||
try {
|
|
||||||
const job = await fetchHostRemoteJob(props.hostId);
|
|
||||||
const out = (job.output || job.stdout || "").trim();
|
|
||||||
if (out) polledOutput.value = out;
|
|
||||||
if (job.message && isLoading.value) polledMessage.value = job.message;
|
|
||||||
|
|
||||||
if (isJobRunning(job)) {
|
|
||||||
sawRunning = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canFinish =
|
|
||||||
isTerminalJob(job) && (sawRunning || jobStartedAfterSession(job));
|
|
||||||
if (canFinish) {
|
|
||||||
if (!finishedEmitted) {
|
|
||||||
finishedEmitted = true;
|
|
||||||
applyFinishedJob(job);
|
|
||||||
emit("finished", job);
|
|
||||||
}
|
|
||||||
stopPoll();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* следующий poll */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPoll() {
|
|
||||||
stopPoll();
|
|
||||||
void pollJobOnce();
|
|
||||||
pollTimer = setInterval(() => void pollJobOnce(), LOG_POLL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetLocalState() {
|
|
||||||
finishedEmitted = false;
|
|
||||||
sawRunning = false;
|
|
||||||
isLoading.value = true;
|
|
||||||
ok.value = null;
|
|
||||||
localTitle.value = "";
|
|
||||||
localMessage.value = "";
|
|
||||||
polledOutput.value = props.output.trim();
|
|
||||||
polledMessage.value = "";
|
|
||||||
stopCountdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.open,
|
|
||||||
(open) => {
|
|
||||||
if (open && isLoading.value) {
|
|
||||||
startPoll();
|
|
||||||
} else {
|
|
||||||
stopPoll();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.loading,
|
|
||||||
(loading) => {
|
|
||||||
if (loading) {
|
|
||||||
resetLocalState();
|
|
||||||
if (props.open) startPoll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!finishedEmitted && props.ok != null) {
|
|
||||||
finishedEmitted = true;
|
|
||||||
isLoading.value = false;
|
|
||||||
ok.value = props.ok;
|
|
||||||
localTitle.value = props.title;
|
|
||||||
localMessage.value = props.message;
|
|
||||||
if (props.output.trim()) polledOutput.value = props.output.trim();
|
|
||||||
if (props.ok) startCountdown();
|
|
||||||
stopPoll();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [displayOutput.value, props.logVisible] as const,
|
|
||||||
async () => {
|
|
||||||
if (!props.logVisible) return;
|
|
||||||
await nextTick();
|
|
||||||
const el = logPreRef.value;
|
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopPoll();
|
|
||||||
stopCountdown();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -286,14 +71,6 @@ onUnmounted(() => {
|
|||||||
padding: 0.85rem 1rem 1rem;
|
padding: 0.85rem 1rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-action-log-panel-done {
|
|
||||||
border-color: #3fb950;
|
|
||||||
}
|
|
||||||
|
|
||||||
.host-action-log-panel-fail {
|
|
||||||
border-color: #f85149;
|
|
||||||
}
|
|
||||||
|
|
||||||
.host-action-log-header {
|
.host-action-log-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -305,22 +82,6 @@ onUnmounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.host-action-log-mark {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.host-action-log-mark-ok {
|
|
||||||
color: #3fb950;
|
|
||||||
}
|
|
||||||
|
|
||||||
.host-action-log-mark-fail {
|
|
||||||
color: #f85149;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-action-log-icon-btn {
|
.host-action-log-icon-btn {
|
||||||
@@ -361,20 +122,6 @@ onUnmounted(() => {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-action-log-result {
|
|
||||||
margin: 0.65rem 0 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.host-action-log-message.success {
|
|
||||||
color: #3fb950;
|
|
||||||
}
|
|
||||||
|
|
||||||
.host-action-log-message.error {
|
|
||||||
color: #f85149;
|
|
||||||
}
|
|
||||||
|
|
||||||
.host-action-log-spinner {
|
.host-action-log-spinner {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="hasOpenLogs" class="host-action-log-stack" aria-live="polite">
|
<div v-if="openLogs.length" class="host-action-log-stack" aria-live="polite">
|
||||||
<template v-for="entry in hostRemoteActionLogs" :key="entry.id">
|
|
||||||
<HostActionLogModal
|
<HostActionLogModal
|
||||||
v-if="entry.open"
|
v-for="entry in openLogs"
|
||||||
:host-id="entry.hostId"
|
:key="entry.id"
|
||||||
:open="true"
|
:open="true"
|
||||||
:title="entry.title"
|
:title="entry.title"
|
||||||
:loading="entry.loading"
|
:loading="entry.loading"
|
||||||
:ok="entry.ok"
|
:ok="entry.ok"
|
||||||
:message="entry.message"
|
:message="entry.message"
|
||||||
:output="entry.output"
|
:output="entry.output"
|
||||||
:log-placeholder="entry.logPlaceholder"
|
|
||||||
:log-visible="entry.logVisible"
|
|
||||||
:session-started-at="entry.sessionStartedAt"
|
|
||||||
@close="closeHostRemoteActionLog(entry.id)"
|
@close="closeHostRemoteActionLog(entry.id)"
|
||||||
@toggle-log="toggleHostRemoteActionLog(entry.id)"
|
|
||||||
@finished="syncHostRemoteActionJobFinished(entry.id, $event)"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -27,11 +20,9 @@ import HostActionLogModal from "./HostActionLogModal.vue";
|
|||||||
import {
|
import {
|
||||||
closeHostRemoteActionLog,
|
closeHostRemoteActionLog,
|
||||||
hostRemoteActionLogs,
|
hostRemoteActionLogs,
|
||||||
syncHostRemoteActionJobFinished,
|
|
||||||
toggleHostRemoteActionLog,
|
|
||||||
} from "../composables/useHostRemoteAction";
|
} from "../composables/useHostRemoteAction";
|
||||||
|
|
||||||
const hasOpenLogs = computed(() => hostRemoteActionLogs.some((e) => e.open));
|
const openLogs = computed(() => hostRemoteActionLogs.filter((e) => e.open));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -16,16 +16,11 @@ export type HostRemoteActionLogEntry = {
|
|||||||
id: string;
|
id: string;
|
||||||
hostId: number;
|
hostId: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
logVisible: boolean;
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
ok: boolean | null;
|
ok: boolean | null;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
output: string;
|
output: string;
|
||||||
/** Текст в окне лога, пока output с сервера ещё пустой */
|
|
||||||
logPlaceholder: string;
|
|
||||||
/** ISO-время открытия окна — отсекаем stale success от прошлого job */
|
|
||||||
sessionStartedAt: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hostRemoteActionLogs = reactive<HostRemoteActionLogEntry[]>([]);
|
export const hostRemoteActionLogs = reactive<HostRemoteActionLogEntry[]>([]);
|
||||||
@@ -86,22 +81,12 @@ function scheduleSuccessAutoClose(entryId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
|
function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
|
||||||
if (job.title) {
|
entry.title = job.title || entry.title;
|
||||||
entry.title = job.title;
|
entry.message = job.message || entry.message;
|
||||||
}
|
|
||||||
if (job.message) {
|
|
||||||
entry.message = job.message;
|
|
||||||
}
|
|
||||||
const nextOutput = (job.output || job.stdout || "").trim();
|
|
||||||
if (nextOutput) {
|
|
||||||
entry.output = job.output || job.stdout || "";
|
entry.output = job.output || job.stdout || "";
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
|
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
|
||||||
if (!entry.loading && entry.ok != null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
entry.loading = false;
|
entry.loading = false;
|
||||||
entry.ok = job.ok ?? job.status === "success";
|
entry.ok = job.ok ?? job.status === "success";
|
||||||
applyJobToEntry(entry, job);
|
applyJobToEntry(entry, job);
|
||||||
@@ -111,16 +96,6 @@ function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncHostRemoteActionJobFinished(
|
|
||||||
entryId: string,
|
|
||||||
job: HostRemoteActionJobStatus,
|
|
||||||
): void {
|
|
||||||
const entry = findLogById(entryId);
|
|
||||||
if (!entry) return;
|
|
||||||
finishEntry(entry, job);
|
|
||||||
void patchHostFromJob(entry.hostId, job);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
|
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const job = await fetchHostRemoteJob(hostId);
|
const job = await fetchHostRemoteJob(hostId);
|
||||||
@@ -191,7 +166,6 @@ async function executeRemoteAction(
|
|||||||
export async function pollHostRemoteAction(
|
export async function pollHostRemoteAction(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
title: string,
|
title: string,
|
||||||
logPlaceholder?: string,
|
|
||||||
): Promise<HostRemoteActionJobStatus | null> {
|
): Promise<HostRemoteActionJobStatus | null> {
|
||||||
const existingPromise = hostRunningPromises.get(hostId);
|
const existingPromise = hostRunningPromises.get(hostId);
|
||||||
if (existingPromise) {
|
if (existingPromise) {
|
||||||
@@ -206,14 +180,11 @@ export async function pollHostRemoteAction(
|
|||||||
id: newLogId(),
|
id: newLogId(),
|
||||||
hostId,
|
hostId,
|
||||||
open: true,
|
open: true,
|
||||||
logVisible: false,
|
|
||||||
loading: true,
|
loading: true,
|
||||||
ok: null,
|
ok: null,
|
||||||
title,
|
title,
|
||||||
message: "Выполняется на удалённом хосте…",
|
message: "Выполняется на удалённом хосте…",
|
||||||
output: "",
|
output: "",
|
||||||
logPlaceholder: logPlaceholder || "Ожидание лога с хоста…",
|
|
||||||
sessionStartedAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
hostRemoteActionLogs.push(entry);
|
hostRemoteActionLogs.push(entry);
|
||||||
|
|
||||||
@@ -244,7 +215,6 @@ export async function runHostRemoteAction(
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
title: string,
|
title: string,
|
||||||
kind: HostRemoteActionKind,
|
kind: HostRemoteActionKind,
|
||||||
logPlaceholder?: string,
|
|
||||||
): Promise<HostRemoteActionJobStatus | null> {
|
): Promise<HostRemoteActionJobStatus | null> {
|
||||||
const existingPromise = hostRunningPromises.get(hostId);
|
const existingPromise = hostRunningPromises.get(hostId);
|
||||||
if (existingPromise) {
|
if (existingPromise) {
|
||||||
@@ -259,14 +229,11 @@ export async function runHostRemoteAction(
|
|||||||
id: newLogId(),
|
id: newLogId(),
|
||||||
hostId,
|
hostId,
|
||||||
open: true,
|
open: true,
|
||||||
logVisible: false,
|
|
||||||
loading: true,
|
loading: true,
|
||||||
ok: null,
|
ok: null,
|
||||||
title,
|
title,
|
||||||
message: "Запуск на сервере SAC…",
|
message: "Запуск на сервере SAC…",
|
||||||
output: "",
|
output: "",
|
||||||
logPlaceholder: logPlaceholder || "Ожидание лога с хоста…",
|
|
||||||
sessionStartedAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
hostRemoteActionLogs.push(entry);
|
hostRemoteActionLogs.push(entry);
|
||||||
|
|
||||||
@@ -279,13 +246,6 @@ export async function runHostRemoteAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleHostRemoteActionLog(entryId: string) {
|
|
||||||
const entry = findLogById(entryId);
|
|
||||||
if (!entry) return;
|
|
||||||
entry.logVisible = !entry.logVisible;
|
|
||||||
entry.open = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeHostRemoteActionLog(entryId: string) {
|
export function closeHostRemoteActionLog(entryId: string) {
|
||||||
clearAutoCloseTimer(entryId);
|
clearAutoCloseTimer(entryId);
|
||||||
const entry = findLogById(entryId);
|
const entry = findLogById(entryId);
|
||||||
|
|||||||
@@ -58,37 +58,3 @@ export function remoteActionTitleForHost(host: HostSummary): string {
|
|||||||
}
|
}
|
||||||
return `Обновление ssh-monitor (SSH): ${label}`;
|
return `Обновление ssh-monitor (SSH): ${label}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOG_PLACEHOLDER_SSH_UPDATE =
|
|
||||||
"Ожидание лога с хоста…\n(обновление /var/log/update_script.log)";
|
|
||||||
|
|
||||||
const LOG_PLACEHOLDER_WINRM =
|
|
||||||
"Ожидание вывода с хоста…\n(WinRM: Deploy-LoginMonitor.ps1)";
|
|
||||||
|
|
||||||
const LOG_PLACEHOLDER_GENERIC = "Ожидание лога с хоста…";
|
|
||||||
|
|
||||||
export function remoteActionLogPlaceholder(
|
|
||||||
host: Pick<HostSummary, "os_family" | "product"> | null | undefined,
|
|
||||||
kind: HostRemoteActionKind | null,
|
|
||||||
): string {
|
|
||||||
if (kind === "ssh-update") {
|
|
||||||
return LOG_PLACEHOLDER_SSH_UPDATE;
|
|
||||||
}
|
|
||||||
if (host && isWindowsAgentHost(host)) {
|
|
||||||
return LOG_PLACEHOLDER_WINRM;
|
|
||||||
}
|
|
||||||
if (kind === "fallback") {
|
|
||||||
return LOG_PLACEHOLDER_SSH_UPDATE;
|
|
||||||
}
|
|
||||||
return LOG_PLACEHOLDER_GENERIC;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function remoteActionLogPlaceholderFromTitle(title: string): string {
|
|
||||||
if (/winrm/i.test(title)) {
|
|
||||||
return LOG_PLACEHOLDER_WINRM;
|
|
||||||
}
|
|
||||||
if (/ssh-monitor|\/var\/log\/update_script/i.test(title)) {
|
|
||||||
return LOG_PLACEHOLDER_SSH_UPDATE;
|
|
||||||
}
|
|
||||||
return LOG_PLACEHOLDER_GENERIC;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
/** 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,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
export const APP_NAME = "Security Alert Center";
|
||||||
export const APP_VERSION = "0.5.17";
|
export const APP_VERSION = "0.5.0";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -238,8 +238,6 @@
|
|||||||
|
|
||||||
<th>Пользователь</th>
|
<th>Пользователь</th>
|
||||||
|
|
||||||
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
|
|
||||||
|
|
||||||
<th>Версия агента</th>
|
<th>Версия агента</th>
|
||||||
|
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
@@ -278,8 +276,6 @@
|
|||||||
|
|
||||||
<td>{{ e.actor_user || "—" }}</td>
|
<td>{{ e.actor_user || "—" }}</td>
|
||||||
|
|
||||||
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
|
|
||||||
|
|
||||||
<td>{{ e.product_version || "—" }}</td>
|
<td>{{ e.product_version || "—" }}</td>
|
||||||
|
|
||||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||||
@@ -358,7 +354,6 @@ import RdgQwinstaModal from "../components/RdgQwinstaModal.vue";
|
|||||||
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
import { useRdgQwinsta } from "../composables/useRdgQwinsta";
|
||||||
import { formatServerName } from "../utils/hostDisplay";
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
|
import { hasRdgFlapUi, rdgQwinstaEventId } from "../utils/rdgFlap";
|
||||||
import { formatSessionDuration } from "../utils/sessionDuration";
|
|
||||||
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
|
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
|
||||||
|
|
||||||
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
|
const { qwinstaLoadingId, qwinstaModal, closeQwinstaModal, runQwinsta, runLogoff } = useRdgQwinsta();
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
<th>Хост</th>
|
<th>Хост</th>
|
||||||
<th>Имя сервера</th>
|
<th>Имя сервера</th>
|
||||||
<th>Пользователь</th>
|
<th>Пользователь</th>
|
||||||
<th title="Длительность сессии (из session_duration_sec)">Длительность</th>
|
|
||||||
<th>Версия агента</th>
|
<th>Версия агента</th>
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
@@ -51,7 +50,6 @@
|
|||||||
<td>{{ e.hostname }}</td>
|
<td>{{ e.hostname }}</td>
|
||||||
<td>{{ formatServerName(e.display_name) }}</td>
|
<td>{{ formatServerName(e.display_name) }}</td>
|
||||||
<td>{{ e.actor_user || "—" }}</td>
|
<td>{{ e.actor_user || "—" }}</td>
|
||||||
<td>{{ formatSessionDuration(e.session_duration_sec) }}</td>
|
|
||||||
<td>{{ e.product_version || "—" }}</td>
|
<td>{{ e.product_version || "—" }}</td>
|
||||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||||
<td><code>{{ e.type }}</code></td>
|
<td><code>{{ e.type }}</code></td>
|
||||||
@@ -122,7 +120,6 @@ import {
|
|||||||
type EventsListState,
|
type EventsListState,
|
||||||
} from "../utils/eventsListQuery";
|
} from "../utils/eventsListQuery";
|
||||||
import { formatServerName } from "../utils/hostDisplay";
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
import { formatSessionDuration } from "../utils/sessionDuration";
|
|
||||||
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
|
import { markEventSessionTerminatedInList, showEventSessionTerminateButton } from "../utils/sessionActions";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|||||||
@@ -69,52 +69,6 @@
|
|||||||
<dd>{{ host.event_count ?? 0 }}</dd>
|
<dd>{{ host.event_count ?? 0 }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</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">
|
<div v-if="isWindowsHost" class="host-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -342,8 +296,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
apiFetch,
|
apiFetch,
|
||||||
fetchHost,
|
fetchHost,
|
||||||
fetchHostAccess,
|
|
||||||
updateHostAccess,
|
|
||||||
fetchRecentEvents,
|
fetchRecentEvents,
|
||||||
testHostWinRm,
|
testHostWinRm,
|
||||||
testHostSsh,
|
testHostSsh,
|
||||||
@@ -351,7 +303,6 @@ import {
|
|||||||
patchHostAgentConfig,
|
patchHostAgentConfig,
|
||||||
type EventListResponse,
|
type EventListResponse,
|
||||||
type HostDetail,
|
type HostDetail,
|
||||||
type HostMgmtAccess,
|
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { emitHostPatch } from "../utils/hostPatchBus";
|
import { emitHostPatch } from "../utils/hostPatchBus";
|
||||||
import HostSessionsPanel from "../components/HostSessionsPanel.vue";
|
import HostSessionsPanel from "../components/HostSessionsPanel.vue";
|
||||||
@@ -364,7 +315,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
isLinuxAgentHost,
|
isLinuxAgentHost,
|
||||||
remoteActionKindForHost,
|
remoteActionKindForHost,
|
||||||
remoteActionLogPlaceholder,
|
|
||||||
remoteActionTitleForHost,
|
remoteActionTitleForHost,
|
||||||
type AgentGitVersions,
|
type AgentGitVersions,
|
||||||
} from "../utils/hostAgentUpgrade";
|
} from "../utils/hostAgentUpgrade";
|
||||||
@@ -403,32 +353,6 @@ const configForm = reactive({
|
|||||||
serverDisplayName: "",
|
serverDisplayName: "",
|
||||||
getInventory: true,
|
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 sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let sshProbeSeq = 0;
|
let sshProbeSeq = 0;
|
||||||
@@ -714,7 +638,7 @@ async function runRequestAgentUpdate() {
|
|||||||
async function runAgentFallback() {
|
async function runAgentFallback() {
|
||||||
agentControlMessage.value = "";
|
agentControlMessage.value = "";
|
||||||
const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)";
|
const title = isWindowsHost.value ? "Обновление через WinRM" : "Fallback SSH (ssh-monitor)";
|
||||||
const job = await runHostRemoteAction(hostId.value, title, "fallback", remoteActionLogPlaceholder(host.value, "fallback"));
|
const job = await runHostRemoteAction(hostId.value, title, "fallback");
|
||||||
if (job) {
|
if (job) {
|
||||||
agentControlOk.value = job.ok ?? false;
|
agentControlOk.value = job.ok ?? false;
|
||||||
if (job.ok) {
|
if (job.ok) {
|
||||||
@@ -753,67 +677,6 @@ 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() {
|
async function loadHost() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -828,7 +691,6 @@ async function loadHost() {
|
|||||||
try {
|
try {
|
||||||
const detail = await fetchHost(hostId.value);
|
const detail = await fetchHost(hostId.value);
|
||||||
applyHostDetail(detail);
|
applyHostDetail(detail);
|
||||||
void loadHostAccess();
|
|
||||||
if (isLinuxHostDetail(detail)) {
|
if (isLinuxHostDetail(detail)) {
|
||||||
void probeSshOnOpen();
|
void probeSshOnOpen();
|
||||||
}
|
}
|
||||||
@@ -844,7 +706,6 @@ async function runAgentUpdate() {
|
|||||||
hostId.value,
|
hostId.value,
|
||||||
"Обновление ssh-monitor (SSH)",
|
"Обновление ssh-monitor (SSH)",
|
||||||
"ssh-update",
|
"ssh-update",
|
||||||
remoteActionLogPlaceholder(host.value, "ssh-update"),
|
|
||||||
);
|
);
|
||||||
if (job?.ok) {
|
if (job?.ok) {
|
||||||
const detail = await fetchHost(hostId.value);
|
const detail = await fetchHost(hostId.value);
|
||||||
@@ -867,7 +728,6 @@ async function runAgentUpgradeFromCell() {
|
|||||||
hostId.value,
|
hostId.value,
|
||||||
remoteActionTitleForHost(host.value),
|
remoteActionTitleForHost(host.value),
|
||||||
kind,
|
kind,
|
||||||
remoteActionLogPlaceholder(host.value, kind),
|
|
||||||
);
|
);
|
||||||
if (job?.ok) {
|
if (job?.ok) {
|
||||||
const detail = await fetchHost(hostId.value);
|
const detail = await fetchHost(hostId.value);
|
||||||
@@ -985,49 +845,6 @@ watch(
|
|||||||
color: #9aa4b2;
|
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 {
|
.host-actions {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -135,12 +135,9 @@ import { isAgentVersionOutdated } from "../utils/agentVersion";
|
|||||||
import {
|
import {
|
||||||
isGitAgentUpgradeAvailable,
|
isGitAgentUpgradeAvailable,
|
||||||
remoteActionKindForHost,
|
remoteActionKindForHost,
|
||||||
remoteActionLogPlaceholder,
|
|
||||||
remoteActionLogPlaceholderFromTitle,
|
|
||||||
remoteActionTitleForHost,
|
remoteActionTitleForHost,
|
||||||
} from "../utils/hostAgentUpgrade";
|
} from "../utils/hostAgentUpgrade";
|
||||||
import {
|
import {
|
||||||
hostRemoteActionLogs,
|
|
||||||
isHostRemoteActionActive,
|
isHostRemoteActionActive,
|
||||||
pollHostRemoteAction,
|
pollHostRemoteAction,
|
||||||
runHostRemoteAction,
|
runHostRemoteAction,
|
||||||
@@ -247,37 +244,6 @@ function isVersionOutdated(h: HostSummary): boolean {
|
|||||||
return isAgentVersionOutdated(h.product_version, reference);
|
return isAgentVersionOutdated(h.product_version, reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
function countOutdatedUpgradeableHosts(): number {
|
|
||||||
return (data.value?.items ?? []).filter(
|
|
||||||
(h) => isGitAgentUpgradeAvailable(h, data.value) && isVersionOutdated(h),
|
|
||||||
).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countActiveRemoteUpgrades(): number {
|
|
||||||
return hostRemoteActionLogs.filter((e) => e.loading).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmMassUpgradeIfNeeded(hostname: string): boolean {
|
|
||||||
const active = countActiveRemoteUpgrades();
|
|
||||||
const outdated = countOutdatedUpgradeableHosts();
|
|
||||||
if (active >= 2) {
|
|
||||||
return window.confirm(
|
|
||||||
`Сейчас обновляется ${active} хост(ов). Рекомендуется пачками по 2–3. Продолжить обновление «${hostname}»?`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (outdated > 3 && active >= 1) {
|
|
||||||
return window.confirm(
|
|
||||||
`Устарело агентов на странице: ${outdated}, уже идёт обновление. Продолжить «${hostname}»?`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (outdated > 3) {
|
|
||||||
return window.confirm(
|
|
||||||
`Устарело агентов на странице: ${outdated}. Рекомендуется обновлять пачками по 2–3, не все сразу. Продолжить «${hostname}»?`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startAgentUpgrade(h: HostSummary) {
|
async function startAgentUpgrade(h: HostSummary) {
|
||||||
if (!isGitAgentUpgradeAvailable(h, data.value)) {
|
if (!isGitAgentUpgradeAvailable(h, data.value)) {
|
||||||
return;
|
return;
|
||||||
@@ -286,17 +252,9 @@ async function startAgentUpgrade(h: HostSummary) {
|
|||||||
showHostRemoteActionLog(h.id);
|
showHostRemoteActionLog(h.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirmMassUpgradeIfNeeded(h.hostname)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const kind = remoteActionKindForHost(h);
|
const kind = remoteActionKindForHost(h);
|
||||||
if (!kind) return;
|
if (!kind) return;
|
||||||
void runHostRemoteAction(
|
void runHostRemoteAction(h.id, remoteActionTitleForHost(h), kind);
|
||||||
h.id,
|
|
||||||
remoteActionTitleForHost(h),
|
|
||||||
kind,
|
|
||||||
remoteActionLogPlaceholder(h, kind),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openHost(id: number) {
|
function openHost(id: number) {
|
||||||
@@ -457,11 +415,7 @@ interface HostDeleteResponse {
|
|||||||
|
|
||||||
async function onManualAddStarted(payload: { hostId: number; title: string }) {
|
async function onManualAddStarted(payload: { hostId: number; title: string }) {
|
||||||
await loadHosts();
|
await loadHosts();
|
||||||
void pollHostRemoteAction(
|
void pollHostRemoteAction(payload.hostId, payload.title);
|
||||||
payload.hostId,
|
|
||||||
payload.title,
|
|
||||||
remoteActionLogPlaceholderFromTitle(payload.title),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete(h: HostSummary) {
|
async function confirmDelete(h: HostSummary) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
<h1>Настройки</h1>
|
<h1>Настройки</h1>
|
||||||
<p class="settings-intro">
|
<p class="settings-intro">
|
||||||
@@ -126,9 +126,8 @@
|
|||||||
<section class="card settings-card settings-policy">
|
<section class="card settings-card settings-policy">
|
||||||
<h2>Windows (qwinsta / logoff)</h2>
|
<h2>Windows (qwinsta / logoff)</h2>
|
||||||
<p class="settings-hint settings-hint-top">
|
<p class="settings-hint settings-hint-top">
|
||||||
Глобальный (доменный) admin для удалённых команд на Windows-хостах (qwinsta, logoff, WinRM-update).
|
Доменный admin для удалённых команд на Windows-хостах (qwinsta, logoff через агент).
|
||||||
Формат логина: <code>ДОМЕН\пользователь</code> (например <code>B26\papatramp</code>).
|
Формат логина: <code>ДОМЕН\пользователь</code> (например <code>B26\papatramp</code>).
|
||||||
Для отдельного ПК (home/workgroup) задайте override в карточке <strong>Хосты → доступ к хосту</strong>.
|
|
||||||
Пока запись не создана в UI — используются <code>SAC_WIN_ADMIN_*</code> из <code>sac-api.env</code>.
|
Пока запись не создана в UI — используются <code>SAC_WIN_ADMIN_*</code> из <code>sac-api.env</code>.
|
||||||
</p>
|
</p>
|
||||||
<form class="settings-form" @submit.prevent="saveWinAdminSettings">
|
<form class="settings-form" @submit.prevent="saveWinAdminSettings">
|
||||||
@@ -187,9 +186,8 @@
|
|||||||
<section class="card settings-card settings-policy">
|
<section class="card settings-card settings-policy">
|
||||||
<h2>Linux (SSH / обновление ssh-monitor)</h2>
|
<h2>Linux (SSH / обновление ssh-monitor)</h2>
|
||||||
<p class="settings-hint settings-hint-top">
|
<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>.
|
Рекомендуется <code>root</code> или пользователь с <code>sudo NOPASSWD</code>.
|
||||||
Для отдельного сервера задайте override в карточке <strong>Хосты → доступ к хосту</strong>.
|
|
||||||
Пока запись не создана в UI — используются <code>SAC_LINUX_ADMIN_*</code> из <code>sac-api.env</code>.
|
Пока запись не создана в UI — используются <code>SAC_LINUX_ADMIN_*</code> из <code>sac-api.env</code>.
|
||||||
</p>
|
</p>
|
||||||
<form class="settings-form" @submit.prevent="saveLinuxAdminSettings">
|
<form class="settings-form" @submit.prevent="saveLinuxAdminSettings">
|
||||||
@@ -253,7 +251,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="agentUpdateForm.rdp_git_repo_url"
|
v-model="agentUpdateForm.rdp_git_repo_url"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
|
placeholder="https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
@@ -261,7 +259,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="agentUpdateForm.ssh_git_repo_url"
|
v-model="agentUpdateForm.ssh_git_repo_url"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
|
placeholder="https://git.kalinamall.ru/PapaTramp/ssh-monitor.git"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.papatramp.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
|
"$id": "https://git.kalinamall.ru/PapaTramp/security-alert-center/schemas/event-v1.json",
|
||||||
"title": "Security Alert Center Event v1",
|
"title": "Security Alert Center Event v1",
|
||||||
"description": "Каноническое событие от ssh-monitor или RDP-login-monitor",
|
"description": "Каноническое событие от ssh-monitor или RDP-login-monitor",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -94,7 +94,6 @@
|
|||||||
"session.logind.new",
|
"session.logind.new",
|
||||||
"rdp.login.success",
|
"rdp.login.success",
|
||||||
"rdp.login.failed",
|
"rdp.login.failed",
|
||||||
"rdp.session.logoff",
|
|
||||||
"rdp.shadow.control.started",
|
"rdp.shadow.control.started",
|
||||||
"rdp.shadow.control.stopped",
|
"rdp.shadow.control.stopped",
|
||||||
"rdp.shadow.control.permission",
|
"rdp.shadow.control.permission",
|
||||||
|
|||||||
Regular → Executable
@@ -7,8 +7,8 @@ REMOTE_CMD = (
|
|||||||
"set -e\n"
|
"set -e\n"
|
||||||
"TMP=$(mktemp -d)\n"
|
"TMP=$(mktemp -d)\n"
|
||||||
'trap "rm -rf \\"$TMP\\"" EXIT\n'
|
'trap "rm -rf \\"$TMP\\"" EXIT\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.kalinamall.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"
|
"git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/ssh-monitor.git \"$TMP/ssh\"\n"
|
||||||
"echo RDP_version_txt:\n"
|
"echo RDP_version_txt:\n"
|
||||||
"cat \"$TMP/rdp/version.txt\" 2>/dev/null || echo missing\n"
|
"cat \"$TMP/rdp/version.txt\" 2>/dev/null || echo missing\n"
|
||||||
"echo RDP_script:\n"
|
"echo RDP_script:\n"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
set -e
|
set -e
|
||||||
TMP=$(mktemp -d)
|
TMP=$(mktemp -d)
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git "$TMP/rdp"
|
git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git "$TMP/rdp"
|
||||||
git clone --depth 1 -q https://git.papatramp.ru/PapaTramp/ssh-monitor.git "$TMP/ssh"
|
git clone --depth 1 -q https://git.kalinamall.ru/PapaTramp/ssh-monitor.git "$TMP/ssh"
|
||||||
echo RDP_version_txt:
|
echo RDP_version_txt:
|
||||||
cat "$TMP/rdp/version.txt" 2>/dev/null || echo missing
|
cat "$TMP/rdp/version.txt" 2>/dev/null || echo missing
|
||||||
echo RDP_script:
|
echo RDP_script:
|
||||||
|
|||||||
Reference in New Issue
Block a user