chore(home): mirror from kalinamall (9883e6a) with papatramp URLs
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
# Shell scripts must use LF — CRLF in shebang breaks systemd (status=203/EXEC).
|
||||||
|
*.sh text eol=lf
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
deploy/.env
|
||||||
|
|
||||||
|
# Node / frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
.cursor/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# One-off remote deploy helper (never commit credentials)
|
||||||
|
deploy/_remote_sac_deploy_once.py
|
||||||
|
deploy/_remote_sac_verify.py
|
||||||
|
deploy/_ssh_test_*.py
|
||||||
|
|
||||||
|
# Runtime / deploy secrets
|
||||||
|
/config/local.yaml
|
||||||
|
/deploy/*.local.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# Data
|
||||||
|
*.db
|
||||||
|
pgdata/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
.cursorignore
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Security Alert Center (SAC)
|
||||||
|
|
||||||
|
Центральная платформа: приём событий от агентов, корреляция, UI, оповещения, управление хостами.
|
||||||
|
|
||||||
|
**English:** [README_en.md](README_en.md)
|
||||||
|
|
||||||
|
## Репозитории
|
||||||
|
|
||||||
|
| Репозиторий | Назначение |
|
||||||
|
|-------------|------------|
|
||||||
|
| [ssh-monitor](https://git.papatramp.ru/PapaTramp/ssh-monitor) | Linux-агент |
|
||||||
|
| [RDP-login-monitor](https://git.papatramp.ru/PapaTramp/RDP-login-monitor) | Windows-агент |
|
||||||
|
| **security-alert-center** | Сервер SAC (Ubuntu 24.04) |
|
||||||
|
| [seaca](https://git.papatramp.ru/PapaTramp/seaca) | Android-клиент |
|
||||||
|
|
||||||
|
**Версия:** `0.5.16` · **Деплой:** `sudo /opt/sac-deploy.sh`
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Ingest** событий от агентов ([контракт](docs/agent-integration.md)): SSH/sudo/logind, RDP/RDG/WinRM/SMB, heartbeat, отчёты, inventory
|
||||||
|
- **Problems** — автокорреляция (в т.ч. RDG 302→303 flap за 1–10 с)
|
||||||
|
- **Оповещения** — Telegram, email, webhook, FCM (Seaca); severity + cooldown
|
||||||
|
- **UI** — события, хосты, problems, отчёты, обзор (SSE live), настройки (admin)
|
||||||
|
- **RDG flap** — бейдж на событиях 302/303, **qwinsta/logoff** по WinRM на клиентский ПК (нужен domain admin в настройках); опционально — автоотключение зависшей сессии при flap
|
||||||
|
- **Хосты** — статус агента, inventory, обновление ssh-monitor/RDP (SSH/WinRM), тест Git/SSH/WinRM; WinRM RDP: SAC тянет пакет с git, клиент скачивает zip с SAC, локальный `Deploy-LoginMonitor.ps1` (git на ПК не нужен)
|
||||||
|
- **Мобильные** — enrollment, устройства, push; Seaca: ack/resolve, qwinsta/logoff через API SAC
|
||||||
|
- **Роли** — `admin` / `monitor`; JWT, rate limit входа
|
||||||
|
- **Безопасность (0.5.0)** — проверка host key SSH, SSE через httpOnly-cookie, защита rate limit от спуфинга `X-Forwarded-For`, `SAC_SECURITY_ENFORCE`, HMAC для API-ключей
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
| Документ | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| [docs/agent-integration.md](docs/agent-integration.md) | Агенты, `UseSAC`, типы событий |
|
||||||
|
| [docs/agent-control-plane.md](docs/agent-control-plane.md) | RDG flap, qwinsta, обновления агентов |
|
||||||
|
| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | Установка (native) |
|
||||||
|
| [docs/deployment.md](docs/deployment.md) | Эксплуатация |
|
||||||
|
| [docs/seaca-mobile.md](docs/seaca-mobile.md) | Мобильные устройства |
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||||
|
# deploy/env.native.example → config/sac-api.env (JWT_SECRET, CORS_ORIGINS, SAC_SECURITY_ENFORCE)
|
||||||
|
cd /opt/security-alert-center/backend && python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT — [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Security Alert Center (SAC)
|
||||||
|
|
||||||
|
Self-hosted hub for security events from Linux and Windows agents: ingest, correlation, UI, notifications, host management.
|
||||||
|
|
||||||
|
**Русский:** [README.md](README.md)
|
||||||
|
|
||||||
|
## Repositories
|
||||||
|
|
||||||
|
| Repository | Role |
|
||||||
|
|------------|------|
|
||||||
|
| [ssh-monitor](https://git.papatramp.ru/PapaTramp/ssh-monitor) | Linux agent |
|
||||||
|
| [RDP-login-monitor](https://git.papatramp.ru/PapaTramp/RDP-login-monitor) | Windows agent |
|
||||||
|
| **security-alert-center** | SAC server (Ubuntu 24.04) |
|
||||||
|
| [seaca](https://git.papatramp.ru/PapaTramp/seaca) | Android client |
|
||||||
|
|
||||||
|
**Version:** `0.5.16` · **Deploy:** `sudo /opt/sac-deploy.sh`
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Ingest** from agents ([contract](docs/agent-integration.md)): SSH/sudo/logind, RDP/RDG/WinRM/SMB, heartbeat, reports, inventory
|
||||||
|
- **Problems** — auto-correlation (incl. RDG 302→303 flap within 1–10 s)
|
||||||
|
- **Notifications** — Telegram, email, webhook, FCM (Seaca)
|
||||||
|
- **Web UI** — events, hosts, problems, reports, dashboard (SSE), settings (admin)
|
||||||
|
- **RDG flap** — badge on 302/303 events, **qwinsta/logoff** via WinRM to client PC (domain admin required); optional auto-disconnect of stuck sessions
|
||||||
|
- **Hosts** — agent status, inventory, agent updates (SSH/WinRM); WinRM RDP: SAC fetches from git, client downloads zip from SAC, runs local `Deploy-LoginMonitor.ps1` (no git on PC)
|
||||||
|
- **Mobile** — enrollment, devices, push; Seaca: ack/resolve, qwinsta/logoff via SAC API
|
||||||
|
- **Roles** — `admin` / `monitor`
|
||||||
|
- **Security (0.5.0)** — SSH host-key verification, SSE via httpOnly cookie, anti-spoof login rate limit, `SAC_SECURITY_ENFORCE`, HMAC API keys
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| [docs/agent-integration.md](docs/agent-integration.md) | Agents, `UseSAC`, event types |
|
||||||
|
| [docs/agent-control-plane.md](docs/agent-control-plane.md) | RDG flap, qwinsta, agent updates |
|
||||||
|
| [docs/install-ubuntu-24.04-native.md](docs/install-ubuntu-24.04-native.md) | Native install |
|
||||||
|
| [docs/deployment.md](docs/deployment.md) | Operations |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.papatramp.ru/PapaTramp/security-alert-center.git /opt/security-alert-center
|
||||||
|
# deploy/env.native.example → config/sac-api.env
|
||||||
|
cd /opt/security-alert-center/backend && python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt && .venv/bin/alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/app ./app
|
||||||
|
COPY backend/alembic.ini .
|
||||||
|
COPY backend/alembic ./alembic
|
||||||
|
COPY schemas ./schemas
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV EVENT_SCHEMA_PATH=/app/schemas/event-schema-v1.json
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Backend — Security Alert Center
|
||||||
|
|
||||||
|
FastAPI + PostgreSQL + Alembic.
|
||||||
|
|
||||||
|
## Production (native на Ubuntu 24.04)
|
||||||
|
|
||||||
|
См. **[docs/install-ubuntu-24.04-native.md](../docs/install-ubuntu-24.04-native.md)**.
|
||||||
|
|
||||||
|
Кратко:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/security-alert-center/backend
|
||||||
|
python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||||
|
# /opt/security-alert-center/config/sac-api.env — см. deploy/env.native.example
|
||||||
|
alembic upgrade head
|
||||||
|
systemctl start sac-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Локальная разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3.12 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
export DATABASE_URL=postgresql+psycopg2://sac:sac@localhost:5432/sac
|
||||||
|
export SAC_BOOTSTRAP_API_KEY=sac_dev_test_key
|
||||||
|
export EVENT_SCHEMA_PATH=../schemas/event-schema-v1.json
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker (альтернатива)
|
||||||
|
|
||||||
|
[docs/install-ubuntu-24.04-docker.md](../docs/install-ubuntu-24.04-docker.md)
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Метод | Путь | Auth |
|
||||||
|
|-------|------|------|
|
||||||
|
| GET | `/health` | нет |
|
||||||
|
| POST | `/api/v1/events` | Bearer API key |
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import ApiKey, Event, Host # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
settings = get_settings()
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""initial hosts events api_keys
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-05-26
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "001"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"hosts",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("agent_instance_id", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("hostname", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("os_family", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("os_version", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("product", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("product_version", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("ipv4", sa.String(length=45), nullable=True),
|
||||||
|
sa.Column("ipv6", sa.String(length=45), nullable=True),
|
||||||
|
sa.Column("tags", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("use_sac_mode", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("agent_instance_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_hosts_hostname", "hosts", ["hostname"])
|
||||||
|
op.create_index("ix_hosts_product", "hosts", ["product"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"api_keys",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("key_prefix", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("key_hash", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("key_hash"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_api_keys_key_prefix", "api_keys", ["key_prefix"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"events",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("event_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("host_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("received_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("category", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("type", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=256), nullable=False),
|
||||||
|
sa.Column("summary", sa.Text(), nullable=False),
|
||||||
|
sa.Column("details", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("raw", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("dedup_key", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column("correlation_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("event_id", name="uq_events_event_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_events_event_id", "events", ["event_id"])
|
||||||
|
op.create_index("ix_events_host_id", "events", ["host_id"])
|
||||||
|
op.create_index("ix_events_occurred_at", "events", ["occurred_at"])
|
||||||
|
op.create_index("ix_events_category", "events", ["category"])
|
||||||
|
op.create_index("ix_events_type", "events", ["type"])
|
||||||
|
op.create_index("ix_events_severity", "events", ["severity"])
|
||||||
|
op.create_index("ix_events_dedup_key", "events", ["dedup_key"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("events")
|
||||||
|
op.drop_table("api_keys")
|
||||||
|
op.drop_table("hosts")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""problems table
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "002"
|
||||||
|
down_revision: Union[str, None] = "001"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"problems",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("host_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("title", sa.String(length=256), nullable=False),
|
||||||
|
sa.Column("summary", sa.Text(), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
|
||||||
|
sa.Column("rule_id", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_problems_status", "problems", ["status"])
|
||||||
|
op.create_index("ix_problems_severity", "problems", ["severity"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"problem_events",
|
||||||
|
sa.Column("problem_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_id", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["problem_id"], ["problems.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("problem_id", "event_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("problem_events")
|
||||||
|
op.drop_table("problems")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""problem correlation fields
|
||||||
|
|
||||||
|
Revision ID: 003
|
||||||
|
Revises: 002
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "003"
|
||||||
|
down_revision: Union[str, None] = "002"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("problems", sa.Column("fingerprint", sa.String(length=128), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"problems",
|
||||||
|
sa.Column("event_count", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
)
|
||||||
|
op.add_column("problems", sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.create_index("ix_problems_fingerprint", "problems", ["fingerprint"])
|
||||||
|
op.create_index("ix_problems_last_seen_at", "problems", ["last_seen_at"])
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE problems p SET
|
||||||
|
fingerprint = 'h' || COALESCE(p.host_id::text, '0') || chr(58) || 'r'
|
||||||
|
|| COALESCE(p.rule_id, 'unknown'),
|
||||||
|
event_count = COALESCE(
|
||||||
|
(SELECT COUNT(*)::int FROM problem_events pe WHERE pe.problem_id = p.id),
|
||||||
|
1
|
||||||
|
),
|
||||||
|
last_seen_at = COALESCE(p.updated_at, p.created_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.alter_column("problems", "fingerprint", nullable=False)
|
||||||
|
op.alter_column("problems", "last_seen_at", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_problems_last_seen_at", table_name="problems")
|
||||||
|
op.drop_index("ix_problems_fingerprint", table_name="problems")
|
||||||
|
op.drop_column("problems", "last_seen_at")
|
||||||
|
op.drop_column("problems", "event_count")
|
||||||
|
op.drop_column("problems", "fingerprint")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""notification_channels for UI-managed Telegram settings
|
||||||
|
|
||||||
|
Revision ID: 004
|
||||||
|
Revises: 003
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "004"
|
||||||
|
down_revision: Union[str, None] = "003"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"notification_channels",
|
||||||
|
sa.Column("channel", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("min_severity", sa.String(length=16), nullable=False, server_default="warning"),
|
||||||
|
sa.Column("bot_token", sa.Text(), nullable=True),
|
||||||
|
sa.Column("chat_id", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("channel"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("notification_channels")
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""notification_channels: webhook columns
|
||||||
|
|
||||||
|
Revision ID: 005
|
||||||
|
Revises: 004
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "005"
|
||||||
|
down_revision: Union[str, None] = "004"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("notification_channels", sa.Column("webhook_url", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"notification_channels",
|
||||||
|
sa.Column("webhook_secret_header", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column("notification_channels", sa.Column("webhook_secret", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("notification_channels", "webhook_secret")
|
||||||
|
op.drop_column("notification_channels", "webhook_secret_header")
|
||||||
|
op.drop_column("notification_channels", "webhook_url")
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""notification_channels: SMTP/email columns
|
||||||
|
|
||||||
|
Revision ID: 006
|
||||||
|
Revises: 005
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "006"
|
||||||
|
down_revision: Union[str, None] = "005"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("notification_channels", sa.Column("smtp_host", sa.Text(), nullable=True))
|
||||||
|
op.add_column("notification_channels", sa.Column("smtp_port", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("notification_channels", sa.Column("smtp_user", sa.String(length=128), nullable=True))
|
||||||
|
op.add_column("notification_channels", sa.Column("smtp_password", sa.Text(), nullable=True))
|
||||||
|
op.add_column("notification_channels", sa.Column("mail_from", sa.String(length=256), nullable=True))
|
||||||
|
op.add_column("notification_channels", sa.Column("mail_to", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"notification_channels",
|
||||||
|
sa.Column("smtp_starttls", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"notification_channels",
|
||||||
|
sa.Column("smtp_ssl", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("notification_channels", "smtp_ssl")
|
||||||
|
op.drop_column("notification_channels", "smtp_starttls")
|
||||||
|
op.drop_column("notification_channels", "mail_to")
|
||||||
|
op.drop_column("notification_channels", "mail_from")
|
||||||
|
op.drop_column("notification_channels", "smtp_password")
|
||||||
|
op.drop_column("notification_channels", "smtp_user")
|
||||||
|
op.drop_column("notification_channels", "smtp_port")
|
||||||
|
op.drop_column("notification_channels", "smtp_host")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""notification_policy singleton (global severity rule)
|
||||||
|
|
||||||
|
Revision ID: 007
|
||||||
|
Revises: 006
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "007"
|
||||||
|
down_revision: Union[str, None] = "006"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"notification_policy",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("min_severity", sa.String(length=16), nullable=False, server_default="warning"),
|
||||||
|
sa.Column("use_telegram", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("use_webhook", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("use_email", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO notification_policy (id, min_severity, use_telegram, use_webhook, use_email) "
|
||||||
|
"VALUES (1, 'warning', true, false, false)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("notification_policy")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""notification_cooldown for SAC notify dedup (F-NOT-03)
|
||||||
|
|
||||||
|
Revision ID: 008
|
||||||
|
Revises: 007
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "008"
|
||||||
|
down_revision: Union[str, None] = "007"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"notification_cooldown",
|
||||||
|
sa.Column("cooldown_key", sa.String(length=512), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("last_notified_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("cooldown_key"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_notification_cooldown_kind", "notification_cooldown", ["kind"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_notification_cooldown_kind", table_name="notification_cooldown")
|
||||||
|
op.drop_table("notification_cooldown")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""sac_users table for multi-user UI login
|
||||||
|
|
||||||
|
Revision ID: 009
|
||||||
|
Revises: 008
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "009"
|
||||||
|
down_revision: Union[str, None] = "008"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"sac_users",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("username", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=16), nullable=False, server_default="monitor"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("username"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sac_users_username", "sac_users", ["username"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_sac_users_username", table_name="sac_users")
|
||||||
|
op.drop_table("sac_users")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""event_severity_overrides table
|
||||||
|
|
||||||
|
Revision ID: 010
|
||||||
|
Revises: 009
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "010"
|
||||||
|
down_revision: Union[str, None] = "009"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"event_severity_overrides",
|
||||||
|
sa.Column("event_type", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("event_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("event_severity_overrides")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""login_attempts for SAC UI login rate limiting
|
||||||
|
|
||||||
|
Revision ID: 011
|
||||||
|
Revises: 010
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "011"
|
||||||
|
down_revision: Union[str, None] = "010"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"login_attempts",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("ip_address", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("username", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("success", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_login_attempts_ip_address", "login_attempts", ["ip_address"], unique=False)
|
||||||
|
op.create_index("ix_login_attempts_created_at", "login_attempts", ["created_at"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_login_attempts_created_at", table_name="login_attempts")
|
||||||
|
op.drop_index("ix_login_attempts_ip_address", table_name="login_attempts")
|
||||||
|
op.drop_table("login_attempts")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""ui_settings singleton for SAC UI preferences
|
||||||
|
|
||||||
|
Revision ID: 012
|
||||||
|
Revises: 011
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "012"
|
||||||
|
down_revision: Union[str, None] = "011"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("show_sidebar_system_stats", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"INSERT INTO ui_settings (id, show_sidebar_system_stats) VALUES (1, true)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("ui_settings")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""host inventory JSONB
|
||||||
|
|
||||||
|
Revision ID: 013
|
||||||
|
Revises: 012
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
revision: str = "013"
|
||||||
|
down_revision: Union[str, None] = "012"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("hosts", sa.Column("inventory", JSONB, nullable=True))
|
||||||
|
op.add_column("hosts", sa.Column("inventory_updated_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("hosts", "inventory_updated_at")
|
||||||
|
op.drop_column("hosts", "inventory")
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""mobile settings, enrollment codes, devices, refresh tokens; policy use_mobile
|
||||||
|
|
||||||
|
Revision ID: 014
|
||||||
|
Revises: 013
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "014"
|
||||||
|
down_revision: Union[str, None] = "013"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
MOBILE_SETTINGS_ROW_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"notification_policy",
|
||||||
|
sa.Column("use_mobile", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"mobile_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("devices_allowed", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("max_devices_per_user", sa.Integer(), nullable=False, server_default="3"),
|
||||||
|
sa.Column("min_app_version", sa.String(length=32), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO mobile_settings (id, devices_allowed, max_devices_per_user) "
|
||||||
|
f"VALUES ({MOBILE_SETTINGS_ROW_ID}, false, 3)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"mobile_enrollment_codes",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=128), nullable=False, server_default=""),
|
||||||
|
sa.Column("code_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("code_prefix", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("target_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("login_mode", sa.String(length=16), nullable=False, server_default="password"),
|
||||||
|
sa.Column("max_uses", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("use_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["sac_users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["target_user_id"], ["sac_users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_mobile_enrollment_codes_code_hash", "mobile_enrollment_codes", ["code_hash"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"mobile_devices",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("device_uuid", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=128), nullable=False, server_default=""),
|
||||||
|
sa.Column("platform", sa.String(length=32), nullable=False, server_default="android"),
|
||||||
|
sa.Column("app_version", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("fcm_token", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column("fcm_token_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("enrollment_code_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("enrolled_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["sac_users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["enrollment_code_id"], ["mobile_enrollment_codes.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_mobile_devices_device_uuid", "mobile_devices", ["device_uuid"], unique=True)
|
||||||
|
op.create_index("ix_mobile_devices_user_id", "mobile_devices", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"mobile_refresh_tokens",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("device_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["device_id"], ["mobile_devices.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_mobile_refresh_tokens_token_hash", "mobile_refresh_tokens", ["token_hash"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_mobile_refresh_tokens_token_hash", table_name="mobile_refresh_tokens")
|
||||||
|
op.drop_table("mobile_refresh_tokens")
|
||||||
|
op.drop_index("ix_mobile_devices_user_id", table_name="mobile_devices")
|
||||||
|
op.drop_index("ix_mobile_devices_device_uuid", table_name="mobile_devices")
|
||||||
|
op.drop_table("mobile_devices")
|
||||||
|
op.drop_index("ix_mobile_enrollment_codes_code_hash", table_name="mobile_enrollment_codes")
|
||||||
|
op.drop_table("mobile_enrollment_codes")
|
||||||
|
op.drop_table("mobile_settings")
|
||||||
|
op.drop_column("notification_policy", "use_mobile")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""problems.resolved_by — manual vs auto close for host_silence cooldown
|
||||||
|
|
||||||
|
Revision ID: 015
|
||||||
|
Revises: 014
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "015"
|
||||||
|
down_revision: Union[str, None] = "014"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"problems",
|
||||||
|
sa.Column("resolved_by", sa.String(length=16), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("problems", "resolved_by")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Revision ID: 016
|
||||||
|
Revises: 015
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "016"
|
||||||
|
down_revision: Union[str, None] = "015"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"agent_commands",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("command_uuid", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("host_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("command_type", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("result_stdout", sa.Text(), nullable=True),
|
||||||
|
sa.Column("result_stderr", sa.Text(), nullable=True),
|
||||||
|
sa.Column("requested_by", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("command_uuid"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_agent_commands_command_uuid", "agent_commands", ["command_uuid"])
|
||||||
|
op.create_index("ix_agent_commands_host_id", "agent_commands", ["host_id"])
|
||||||
|
op.create_index("ix_agent_commands_status", "agent_commands", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_agent_commands_status", table_name="agent_commands")
|
||||||
|
op.drop_index("ix_agent_commands_host_id", table_name="agent_commands")
|
||||||
|
op.drop_index("ix_agent_commands_command_uuid", table_name="agent_commands")
|
||||||
|
op.drop_table("agent_commands")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""ui_settings: Windows domain admin for agent commands
|
||||||
|
|
||||||
|
Revision ID: 017
|
||||||
|
Revises: 016
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "017"
|
||||||
|
down_revision: Union[str, None] = "016"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("ui_settings", sa.Column("win_admin_user", sa.String(256), nullable=True))
|
||||||
|
op.add_column("ui_settings", sa.Column("win_admin_password", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ui_settings", "win_admin_password")
|
||||||
|
op.drop_column("ui_settings", "win_admin_user")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""ui_settings: Linux SSH admin for remote agent updates
|
||||||
|
|
||||||
|
Revision ID: 018
|
||||||
|
Revises: 017
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "018"
|
||||||
|
down_revision: Union[str, None] = "017"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("ui_settings", sa.Column("linux_admin_user", sa.String(128), nullable=True))
|
||||||
|
op.add_column("ui_settings", sa.Column("linux_admin_password", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ui_settings", "linux_admin_password")
|
||||||
|
op.drop_column("ui_settings", "linux_admin_user")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""hosts: persist Linux SSH admin check status
|
||||||
|
|
||||||
|
Revision ID: 019
|
||||||
|
Revises: 018
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "019"
|
||||||
|
down_revision: Union[str, None] = "018"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("hosts", sa.Column("ssh_admin_ok", sa.Boolean(), nullable=True))
|
||||||
|
op.add_column("hosts", sa.Column("ssh_admin_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("hosts", "ssh_admin_checked_at")
|
||||||
|
op.drop_column("hosts", "ssh_admin_ok")
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""agent control plane: host update/config + ui agent-update settings
|
||||||
|
|
||||||
|
Revision ID: 020
|
||||||
|
Revises: 019
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
revision: str = "020"
|
||||||
|
down_revision: Union[str, None] = "019"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"hosts",
|
||||||
|
sa.Column("pending_agent_update", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"hosts",
|
||||||
|
sa.Column("pending_update_requested_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"hosts",
|
||||||
|
sa.Column("pending_update_target_version", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"hosts",
|
||||||
|
sa.Column("agent_config", JSONB, nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"hosts",
|
||||||
|
sa.Column("agent_config_revision", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"hosts",
|
||||||
|
sa.Column("agent_update_state", sa.String(length=32), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"hosts",
|
||||||
|
sa.Column("agent_update_last_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column("hosts", sa.Column("agent_update_last_error", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_update_mode", sa.String(length=16), nullable=False, server_default="gpo"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_update_fallback_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_update_fallback_minutes", sa.Integer(), nullable=False, server_default="15"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_recommended_rdp_version", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_recommended_ssh_version", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_min_rdp_version", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_min_ssh_version", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column("ui_settings", sa.Column("win_agent_update_script", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ui_settings", "win_agent_update_script")
|
||||||
|
op.drop_column("ui_settings", "agent_min_ssh_version")
|
||||||
|
op.drop_column("ui_settings", "agent_min_rdp_version")
|
||||||
|
op.drop_column("ui_settings", "agent_recommended_ssh_version")
|
||||||
|
op.drop_column("ui_settings", "agent_recommended_rdp_version")
|
||||||
|
op.drop_column("ui_settings", "agent_update_fallback_minutes")
|
||||||
|
op.drop_column("ui_settings", "agent_update_fallback_enabled")
|
||||||
|
op.drop_column("ui_settings", "agent_update_mode")
|
||||||
|
|
||||||
|
op.drop_column("hosts", "agent_update_last_error")
|
||||||
|
op.drop_column("hosts", "agent_update_last_at")
|
||||||
|
op.drop_column("hosts", "agent_update_state")
|
||||||
|
op.drop_column("hosts", "agent_config_revision")
|
||||||
|
op.drop_column("hosts", "agent_config")
|
||||||
|
op.drop_column("hosts", "pending_update_target_version")
|
||||||
|
op.drop_column("hosts", "pending_update_requested_at")
|
||||||
|
op.drop_column("hosts", "pending_agent_update")
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""agent git release repos and version cache
|
||||||
|
|
||||||
|
Revision ID: 021
|
||||||
|
Revises: 020
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "021"
|
||||||
|
down_revision: Union[str, None] = "020"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_rdp_git_repo_url", sa.String(length=512), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_ssh_git_repo_url", sa.String(length=512), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_git_branch", sa.String(length=64), nullable=False, server_default="main"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_git_rdp_version", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_git_ssh_version", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("agent_git_fetched_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ui_settings", "agent_git_fetched_at")
|
||||||
|
op.drop_column("ui_settings", "agent_git_ssh_version")
|
||||||
|
op.drop_column("ui_settings", "agent_git_rdp_version")
|
||||||
|
op.drop_column("ui_settings", "agent_git_branch")
|
||||||
|
op.drop_column("ui_settings", "agent_ssh_git_repo_url")
|
||||||
|
op.drop_column("ui_settings", "agent_rdp_git_repo_url")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""host remote_action JSON for background SSH/WinRM jobs
|
||||||
|
|
||||||
|
Revision ID: 022_host_remote_action
|
||||||
|
Revises: 021
|
||||||
|
Create Date: 2026-06-21
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "022_host_remote_action"
|
||||||
|
down_revision = "021"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"hosts",
|
||||||
|
sa.Column("remote_action", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("hosts", "remote_action")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""event type visibility settings
|
||||||
|
|
||||||
|
Revision ID: 023_event_type_visibility
|
||||||
|
Revises: 022_host_remote_action
|
||||||
|
Create Date: 2026-06-21
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "023_event_type_visibility"
|
||||||
|
down_revision = "022_host_remote_action"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"event_type_visibility",
|
||||||
|
sa.Column("event_type", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("show_in_events", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("event_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("event_type_visibility")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""login security whitelist in ui_settings
|
||||||
|
|
||||||
|
Revision ID: 024_login_security
|
||||||
|
Revises: 023_event_type_visibility
|
||||||
|
Create Date: 2026-06-25
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "024_login_security"
|
||||||
|
down_revision = "023_event_type_visibility"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("ui_settings", sa.Column("login_ip_whitelist", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("login_sync_fail2ban", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ui_settings", "login_sync_fail2ban")
|
||||||
|
op.drop_column("ui_settings", "login_ip_whitelist")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""auto RDP flap disconnect setting in ui_settings
|
||||||
|
|
||||||
|
Revision ID: 025_auto_rdp_flap_disconnect
|
||||||
|
Revises: 024_login_security
|
||||||
|
Create Date: 2026-07-02
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "025_auto_rdp_flap_disconnect"
|
||||||
|
down_revision = "024_login_security"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ui_settings",
|
||||||
|
sa.Column("auto_rdp_flap_disconnect", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ui_settings", "auto_rdp_flap_disconnect")
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""dedupe host_silence problems + unique active index
|
||||||
|
|
||||||
|
Revision ID: 026_host_silence_dedupe
|
||||||
|
Revises: 025_auto_rdp_flap_disconnect
|
||||||
|
Create Date: 2026-07-03
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "026_host_silence_dedupe"
|
||||||
|
down_revision = "025_auto_rdp_flap_disconnect"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_host_silence_problems() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY p.host_id
|
||||||
|
ORDER BY p.last_seen_at DESC, p.id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM problems p
|
||||||
|
WHERE p.rule_id = 'rule:host_silence'
|
||||||
|
AND p.status IN ('open', 'acknowledged')
|
||||||
|
)
|
||||||
|
UPDATE problems
|
||||||
|
SET status = 'resolved',
|
||||||
|
resolved_by = 'auto',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY lower(h.hostname)
|
||||||
|
ORDER BY p.last_seen_at DESC, p.id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM problems p
|
||||||
|
JOIN hosts h ON h.id = p.host_id
|
||||||
|
WHERE p.rule_id = 'rule:host_silence'
|
||||||
|
AND p.status IN ('open', 'acknowledged')
|
||||||
|
)
|
||||||
|
UPDATE problems
|
||||||
|
SET status = 'resolved',
|
||||||
|
resolved_by = 'auto',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_dedupe_host_silence_problems()
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_problems_host_silence_active
|
||||||
|
ON problems (host_id)
|
||||||
|
WHERE rule_id = 'rule:host_silence'
|
||||||
|
AND status IN ('open', 'acknowledged')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_problems_host_silence_active")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""per-host management credentials (SSH / WinRM override)
|
||||||
|
|
||||||
|
Revision ID: 027_host_mgmt_credentials
|
||||||
|
Revises: 026_host_silence_dedupe
|
||||||
|
Create Date: 2026-07-14
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "027_host_mgmt_credentials"
|
||||||
|
down_revision = "026_host_silence_dedupe"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("hosts", sa.Column("mgmt_user", sa.String(length=256), nullable=True))
|
||||||
|
op.add_column("hosts", sa.Column("mgmt_password", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("hosts", "mgmt_password")
|
||||||
|
op.drop_column("hosts", "mgmt_user")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.api_key import get_api_key_auth
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.agent_commands import (
|
||||||
|
complete_command,
|
||||||
|
resolve_host_by_agent_instance_id,
|
||||||
|
)
|
||||||
|
from app.services.agent_poll import build_agent_poll_payload
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandResultBody(BaseModel):
|
||||||
|
status: str = Field(pattern="^(completed|failed)$")
|
||||||
|
stdout: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandsPollResponse(BaseModel):
|
||||||
|
config_revision: int = 0
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
update: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
commands: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/commands", response_model=AgentCommandsPollResponse)
|
||||||
|
def poll_agent_commands(
|
||||||
|
agent_instance_id: str = Query(..., min_length=1),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_api_key: str = Depends(get_api_key_auth),
|
||||||
|
) -> AgentCommandsPollResponse:
|
||||||
|
host = resolve_host_by_agent_instance_id(db, agent_instance_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown agent_instance_id")
|
||||||
|
payload = build_agent_poll_payload(db, host)
|
||||||
|
return AgentCommandsPollResponse(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/commands/{command_uuid}/result")
|
||||||
|
def post_agent_command_result(
|
||||||
|
command_uuid: str,
|
||||||
|
body: AgentCommandResultBody,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_api_key: str = Depends(get_api_key_auth),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
cmd = complete_command(
|
||||||
|
db,
|
||||||
|
command_uuid,
|
||||||
|
status=body.status,
|
||||||
|
stdout=body.stdout,
|
||||||
|
stderr=body.stderr,
|
||||||
|
)
|
||||||
|
if cmd is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Command not found")
|
||||||
|
db.commit()
|
||||||
|
return {"status": cmd.status, "command_uuid": cmd.command_uuid}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rdp-bundle/{token}")
|
||||||
|
def download_rdp_bundle(token: str) -> Response:
|
||||||
|
from app.services.rdp_bundle_delivery import get_rdp_bundle_zip
|
||||||
|
|
||||||
|
data = get_rdp_bundle_zip(token)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=404, detail="Bundle not found or expired")
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="sac-rdp-bundle.zip"'},
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
|
||||||
|
from app.auth.stream_cookie import clear_stream_auth_cookie, set_stream_auth_cookie
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.login_rate_limit import (
|
||||||
|
ensure_login_allowed,
|
||||||
|
record_login_failure,
|
||||||
|
record_login_success,
|
||||||
|
)
|
||||||
|
from app.services.user_auth import authenticate_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(BaseModel):
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> JSONResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.sac_admin_password and not _has_any_user(db):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
|
||||||
|
)
|
||||||
|
|
||||||
|
ip_address = ensure_login_allowed(db, request)
|
||||||
|
|
||||||
|
user = authenticate_user(db, body.username, body.password)
|
||||||
|
if user is None:
|
||||||
|
record_login_failure(db, ip_address=ip_address, username=body.username)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
|
record_login_success(db, ip_address=ip_address, username=user.username)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
token = create_access_token(user.username, user.role)
|
||||||
|
payload = TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
)
|
||||||
|
response = JSONResponse(content=payload.model_dump())
|
||||||
|
set_stream_auth_cookie(response, token, settings)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=204)
|
||||||
|
def logout() -> Response:
|
||||||
|
settings = get_settings()
|
||||||
|
response = Response(status_code=204)
|
||||||
|
clear_stream_auth_cookie(response, settings)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=MeResponse)
|
||||||
|
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
|
||||||
|
return MeResponse(username=current_user.username, role=current_user.role)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_any_user(db: Session) -> bool:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
try:
|
||||||
|
count = db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
return count > 0
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
return False
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import get_current_user
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Event, Host, Problem
|
||||||
|
from app.schemas.list_models import EventSummary
|
||||||
|
from app.services.event_summary import event_to_summary
|
||||||
|
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
|
||||||
|
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]:
|
||||||
|
"""Последние события по времени приёма ingest (received_at)."""
|
||||||
|
hidden = get_hidden_event_types(db)
|
||||||
|
type_filter = visibility_type_filter(hidden)
|
||||||
|
stmt = (
|
||||||
|
select(Event)
|
||||||
|
.join(Host)
|
||||||
|
.options(joinedload(Event.host))
|
||||||
|
.order_by(Event.received_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if type_filter is not None:
|
||||||
|
stmt = stmt.where(type_filter)
|
||||||
|
rows = db.scalars(stmt).all()
|
||||||
|
return [event_to_summary(e, db) for e in rows]
|
||||||
|
|
||||||
|
|
||||||
|
class TopHostItem(BaseModel):
|
||||||
|
host_id: int
|
||||||
|
hostname: str
|
||||||
|
display_name: str | None = None
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class TopTypeItem(BaseModel):
|
||||||
|
type: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardSummary(BaseModel):
|
||||||
|
events_last_24h: int
|
||||||
|
hosts_total: int
|
||||||
|
hosts_stale: int
|
||||||
|
heartbeats_24h: int
|
||||||
|
daily_reports_24h: int
|
||||||
|
problems_open: int
|
||||||
|
problems_opened_24h: int
|
||||||
|
problems_resolved_24h: int
|
||||||
|
severity_24h: dict[str, int]
|
||||||
|
top_hosts: list[TopHostItem]
|
||||||
|
top_event_types: list[TopTypeItem]
|
||||||
|
recent_events: list[EventSummary]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=DashboardSummary)
|
||||||
|
def dashboard_summary(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
) -> DashboardSummary:
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
hidden = get_hidden_event_types(db)
|
||||||
|
type_filter = visibility_type_filter(hidden)
|
||||||
|
|
||||||
|
events_24h_stmt = select(func.count()).select_from(Event).where(Event.received_at >= since)
|
||||||
|
if type_filter is not None:
|
||||||
|
events_24h_stmt = events_24h_stmt.where(type_filter)
|
||||||
|
events_24h = db.scalar(events_24h_stmt) or 0
|
||||||
|
hosts_total = db.scalar(select(func.count()).select_from(Host)) or 0
|
||||||
|
settings = get_settings()
|
||||||
|
hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes)
|
||||||
|
heartbeats_24h = db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Event)
|
||||||
|
.where(Event.received_at >= since, Event.type == HEARTBEAT_TYPE)
|
||||||
|
) or 0
|
||||||
|
daily_reports_24h = db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Event)
|
||||||
|
.where(Event.received_at >= since, Event.type.in_(DAILY_REPORT_TYPES))
|
||||||
|
) or 0
|
||||||
|
problems_open = (
|
||||||
|
db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0
|
||||||
|
)
|
||||||
|
problems_opened_24h = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count()).select_from(Problem).where(
|
||||||
|
Problem.created_at >= since,
|
||||||
|
Problem.status != "resolved",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
problems_resolved_24h = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count()).select_from(Problem).where(
|
||||||
|
Problem.status == "resolved",
|
||||||
|
Problem.updated_at >= since,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
top_host_stmt = (
|
||||||
|
select(Host.id, Host.hostname, Host.display_name, func.count())
|
||||||
|
.select_from(Event)
|
||||||
|
.join(Host, Event.host_id == Host.id)
|
||||||
|
.where(Event.received_at >= since)
|
||||||
|
)
|
||||||
|
if type_filter is not None:
|
||||||
|
top_host_stmt = top_host_stmt.where(type_filter)
|
||||||
|
top_host_rows = db.execute(
|
||||||
|
top_host_stmt.group_by(Host.id, Host.hostname, Host.display_name)
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
.limit(10)
|
||||||
|
).all()
|
||||||
|
top_hosts = [
|
||||||
|
TopHostItem(host_id=row[0], hostname=row[1], display_name=row[2], count=row[3]) for row in top_host_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
top_type_stmt = select(Event.type, func.count()).where(Event.received_at >= since)
|
||||||
|
if type_filter is not None:
|
||||||
|
top_type_stmt = top_type_stmt.where(type_filter)
|
||||||
|
top_type_rows = db.execute(
|
||||||
|
top_type_stmt.group_by(Event.type).order_by(func.count().desc()).limit(10)
|
||||||
|
).all()
|
||||||
|
top_event_types = [TopTypeItem(type=row[0], count=row[1]) for row in top_type_rows]
|
||||||
|
|
||||||
|
severity_stmt = select(Event.severity, func.count()).where(Event.received_at >= since)
|
||||||
|
if type_filter is not None:
|
||||||
|
severity_stmt = severity_stmt.where(type_filter)
|
||||||
|
severity_rows = db.execute(severity_stmt.group_by(Event.severity)).all()
|
||||||
|
severity_24h = {row[0]: row[1] for row in severity_rows}
|
||||||
|
|
||||||
|
recent_events = fetch_recent_events(db, limit=8)
|
||||||
|
|
||||||
|
return DashboardSummary(
|
||||||
|
events_last_24h=events_24h,
|
||||||
|
hosts_total=hosts_total,
|
||||||
|
hosts_stale=hosts_stale,
|
||||||
|
heartbeats_24h=heartbeats_24h,
|
||||||
|
daily_reports_24h=daily_reports_24h,
|
||||||
|
problems_open=problems_open,
|
||||||
|
problems_opened_24h=problems_opened_24h,
|
||||||
|
problems_resolved_24h=problems_resolved_24h,
|
||||||
|
severity_24h=severity_24h,
|
||||||
|
top_hosts=top_hosts,
|
||||||
|
top_event_types=top_event_types,
|
||||||
|
recent_events=recent_events,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-events", response_model=list[EventSummary])
|
||||||
|
def dashboard_recent_events(
|
||||||
|
limit: int = Query(8, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
) -> list[EventSummary]:
|
||||||
|
return fetch_recent_events(db, limit=limit)
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
from app.auth.api_key import get_api_key_auth
|
||||||
|
from app.auth.jwt_auth import get_current_user, CurrentUser
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Event, Host
|
||||||
|
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
||||||
|
from app.utils.sql_like import escape_ilike_pattern
|
||||||
|
from app.services.agent_commands import (
|
||||||
|
command_response_fields,
|
||||||
|
command_to_dict,
|
||||||
|
get_command_by_uuid,
|
||||||
|
)
|
||||||
|
from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
|
||||||
|
from app.services.host_sessions import (
|
||||||
|
event_session_id,
|
||||||
|
event_session_terminated,
|
||||||
|
event_supports_session_terminate,
|
||||||
|
mark_event_session_terminated,
|
||||||
|
terminate_session_for_event,
|
||||||
|
)
|
||||||
|
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||||
|
from app.services.ssh_connect import (
|
||||||
|
HostNotLinuxError as SshHostNotLinuxError,
|
||||||
|
HostTargetMissingError as SshHostTargetMissingError,
|
||||||
|
)
|
||||||
|
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||||
|
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
|
||||||
|
from app.services.ingest import ingest_event
|
||||||
|
from app.services.event_summary import event_to_summary
|
||||||
|
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
|
||||||
|
from app.services.problems import maybe_create_problem
|
||||||
|
from app.services.rdp_flap_auto_disconnect import maybe_auto_disconnect_stuck_rdp_session
|
||||||
|
from app.services.schema_validate import validate_event_payload
|
||||||
|
from app.services.notify_dispatch import (
|
||||||
|
AUTH_LOGIN_SUCCESS_TYPES,
|
||||||
|
DAILY_REPORT_EVENT_TYPES,
|
||||||
|
LIFECYCLE_EVENT_TYPE,
|
||||||
|
RDG_CONNECTION_TYPES,
|
||||||
|
notify_auth_login,
|
||||||
|
notify_daily_report,
|
||||||
|
notify_event,
|
||||||
|
notify_lifecycle,
|
||||||
|
notify_problem,
|
||||||
|
notify_rdg_connection,
|
||||||
|
schedule_notify_auth_login,
|
||||||
|
schedule_notify_daily_report,
|
||||||
|
schedule_notify_lifecycle,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
|
logger = logging.getLogger("sac.ingest")
|
||||||
|
|
||||||
|
|
||||||
|
class IngestResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
event_id: str
|
||||||
|
created: bool
|
||||||
|
sac_event_url: str
|
||||||
|
problem_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ingest_response(event: Event, *, created: bool) -> IngestResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
base = settings.sac_public_url.rstrip("/")
|
||||||
|
return IngestResponse(
|
||||||
|
status="created" if created else "duplicate",
|
||||||
|
event_id=event.event_id,
|
||||||
|
created=created,
|
||||||
|
sac_event_url=f"{base}/api/v1/events/{event.id}",
|
||||||
|
problem_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=IngestResponse)
|
||||||
|
def post_event(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
payload: dict[str, Any] = Body(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_api_key: str = Depends(get_api_key_auth),
|
||||||
|
) -> JSONResponse:
|
||||||
|
errors = validate_event_payload(payload)
|
||||||
|
if errors:
|
||||||
|
event_id_hint = payload.get("event_id") if isinstance(payload.get("event_id"), str) else None
|
||||||
|
logger.warning(
|
||||||
|
"ingest rejected status=422 event_id=%s errors=%s",
|
||||||
|
event_id_hint,
|
||||||
|
errors[:5],
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=422, detail={"schema_errors": errors[:20]})
|
||||||
|
|
||||||
|
event, created = ingest_event(db, payload)
|
||||||
|
problem = None
|
||||||
|
problem_created = False
|
||||||
|
deferred_daily_report_id: int | None = None
|
||||||
|
deferred_lifecycle_id: int | None = None
|
||||||
|
deferred_auth_login_id: int | None = None
|
||||||
|
if created:
|
||||||
|
problem, problem_created = maybe_create_problem(db, event)
|
||||||
|
maybe_auto_disconnect_stuck_rdp_session(db, event)
|
||||||
|
if event.type in DAILY_REPORT_EVENT_TYPES:
|
||||||
|
deferred_daily_report_id = event.id
|
||||||
|
elif event.type == LIFECYCLE_EVENT_TYPE:
|
||||||
|
deferred_lifecycle_id = event.id
|
||||||
|
elif event.type in AUTH_LOGIN_SUCCESS_TYPES:
|
||||||
|
deferred_auth_login_id = event.id
|
||||||
|
elif event.type in RDG_CONNECTION_TYPES:
|
||||||
|
notify_rdg_connection(event, db=db)
|
||||||
|
else:
|
||||||
|
notify_event(event, db=db)
|
||||||
|
if problem is not None and problem_created:
|
||||||
|
notify_problem(problem, event, db=db)
|
||||||
|
logger.info("ingest created event_id=%s type=%s host_id=%s", event.event_id, event.type, event.host_id)
|
||||||
|
else:
|
||||||
|
logger.info("ingest duplicate event_id=%s", event.event_id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
if deferred_daily_report_id is not None:
|
||||||
|
background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id)
|
||||||
|
if deferred_lifecycle_id is not None:
|
||||||
|
background_tasks.add_task(schedule_notify_lifecycle, deferred_lifecycle_id)
|
||||||
|
if deferred_auth_login_id is not None:
|
||||||
|
background_tasks.add_task(schedule_notify_auth_login, deferred_auth_login_id)
|
||||||
|
|
||||||
|
body = _ingest_response(event, created=created)
|
||||||
|
if problem is not None:
|
||||||
|
body.problem_id = problem.id
|
||||||
|
status_code = 201 if created else 409
|
||||||
|
return JSONResponse(status_code=status_code, content=body.model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_dt(value: str | None, *, end_of_day: bool = False) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
raw = value.strip()
|
||||||
|
if len(raw) == 10 and raw[4] == "-" and raw[7] == "-":
|
||||||
|
dt = datetime.fromisoformat(raw)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
if end_of_day:
|
||||||
|
return dt.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=EventListResponse)
|
||||||
|
def list_events(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
severity: str | None = None,
|
||||||
|
type: str | None = None,
|
||||||
|
host_id: int | None = None,
|
||||||
|
hostname: str | None = None,
|
||||||
|
include_hidden: bool = False,
|
||||||
|
from_time: str | None = Query(None, alias="from"),
|
||||||
|
to_time: str | None = Query(None, alias="to"),
|
||||||
|
q: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
) -> EventListResponse:
|
||||||
|
if include_hidden and host_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="include_hidden requires host_id",
|
||||||
|
)
|
||||||
|
hidden = get_hidden_event_types(db)
|
||||||
|
type_filter = visibility_type_filter(hidden)
|
||||||
|
if include_hidden and host_id is not None:
|
||||||
|
type_filter = None
|
||||||
|
stmt = select(Event).join(Host).options(joinedload(Event.host))
|
||||||
|
count_stmt = select(func.count()).select_from(Event).join(Host)
|
||||||
|
if type_filter is not None:
|
||||||
|
stmt = stmt.where(type_filter)
|
||||||
|
count_stmt = count_stmt.where(type_filter)
|
||||||
|
|
||||||
|
if severity:
|
||||||
|
stmt = stmt.where(Event.severity == severity)
|
||||||
|
count_stmt = count_stmt.where(Event.severity == severity)
|
||||||
|
if type:
|
||||||
|
normalized = type.strip()
|
||||||
|
if normalized:
|
||||||
|
stmt = stmt.where(Event.type.ilike(f"{normalized}%"))
|
||||||
|
count_stmt = count_stmt.where(Event.type.ilike(f"{normalized}%"))
|
||||||
|
if host_id is not None:
|
||||||
|
stmt = stmt.where(Event.host_id == host_id)
|
||||||
|
count_stmt = count_stmt.where(Event.host_id == host_id)
|
||||||
|
if hostname:
|
||||||
|
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||||
|
stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||||
|
count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||||
|
dt_from = _parse_optional_dt(from_time)
|
||||||
|
dt_to = _parse_optional_dt(to_time, end_of_day=True)
|
||||||
|
if dt_from:
|
||||||
|
stmt = stmt.where(Event.occurred_at >= dt_from)
|
||||||
|
count_stmt = count_stmt.where(Event.occurred_at >= dt_from)
|
||||||
|
if dt_to:
|
||||||
|
stmt = stmt.where(Event.occurred_at <= dt_to)
|
||||||
|
count_stmt = count_stmt.where(Event.occurred_at <= dt_to)
|
||||||
|
if q:
|
||||||
|
like = f"%{escape_ilike_pattern(q)}%"
|
||||||
|
stmt = stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
|
||||||
|
count_stmt = count_stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
|
||||||
|
|
||||||
|
total = db.scalar(count_stmt) or 0
|
||||||
|
rows = db.scalars(
|
||||||
|
stmt.order_by(Event.occurred_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
items = [event_to_summary(e, db) for e in rows]
|
||||||
|
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommandResponse(BaseModel):
|
||||||
|
command_uuid: str
|
||||||
|
command_type: str
|
||||||
|
status: str
|
||||||
|
result_stdout: str | None = None
|
||||||
|
result_stderr: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
completed_at: str | None = None
|
||||||
|
target: str | None = None
|
||||||
|
client_hostname: str | None = None
|
||||||
|
internal_ip: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_command_response(cmd) -> AgentCommandResponse:
|
||||||
|
data = command_to_dict(cmd)
|
||||||
|
extra = command_response_fields(cmd)
|
||||||
|
return AgentCommandResponse(
|
||||||
|
command_uuid=data["id"],
|
||||||
|
command_type=data["type"],
|
||||||
|
status=data["status"],
|
||||||
|
result_stdout=data.get("result_stdout"),
|
||||||
|
result_stderr=data.get("result_stderr"),
|
||||||
|
created_at=data.get("created_at"),
|
||||||
|
completed_at=data.get("completed_at"),
|
||||||
|
target=extra.get("target"),
|
||||||
|
client_hostname=extra.get("client_hostname"),
|
||||||
|
internal_ip=extra.get("internal_ip"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LogoffActionBody(BaseModel):
|
||||||
|
session_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class EventSessionTerminateBody(BaseModel):
|
||||||
|
session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EventSessionTerminateResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
target: str | None = None
|
||||||
|
stdout: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_db_id}/actions/terminate-session", response_model=EventSessionTerminateResponse)
|
||||||
|
def post_event_terminate_session(
|
||||||
|
event_db_id: int,
|
||||||
|
body: EventSessionTerminateBody | None = Body(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> EventSessionTerminateResponse:
|
||||||
|
event = db.scalar(
|
||||||
|
select(Event).options(joinedload(Event.host)).where(Event.id == event_db_id)
|
||||||
|
)
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
if not event_supports_session_terminate(event):
|
||||||
|
raise HTTPException(status_code=400, detail="Event type does not support session terminate")
|
||||||
|
if event_session_terminated(event, db=db):
|
||||||
|
raise HTTPException(status_code=409, detail="Session already terminated for this event")
|
||||||
|
|
||||||
|
if event.host is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Event has no host")
|
||||||
|
linux_cfg = get_effective_linux_admin_for_host(db, event.host)
|
||||||
|
win_cfg = get_effective_win_admin_for_host(db, event.host)
|
||||||
|
sid = (body.session_id if body else None) or event_session_id(event)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = terminate_session_for_event(
|
||||||
|
event,
|
||||||
|
linux_cfg=linux_cfg,
|
||||||
|
win_cfg=win_cfg,
|
||||||
|
session_id=sid,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except HostNotWindowsError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except HostTargetMissingError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except SshHostNotLinuxError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except SshHostTargetMissingError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
response = EventSessionTerminateResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
message=result.message,
|
||||||
|
target=getattr(result, "target", None),
|
||||||
|
stdout=getattr(result, "stdout", None) or None,
|
||||||
|
stderr=getattr(result, "stderr", None) or None,
|
||||||
|
)
|
||||||
|
if result.ok:
|
||||||
|
mark_event_session_terminated(event, by_username=user.username)
|
||||||
|
db.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse)
|
||||||
|
def post_event_qwinsta(
|
||||||
|
event_db_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> AgentCommandResponse:
|
||||||
|
event = db.get(Event, event_db_id)
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
cmd = execute_qwinsta_via_winrm(db, event, requested_by=user.username)
|
||||||
|
db.commit()
|
||||||
|
return _agent_command_response(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_db_id}/actions/logoff", response_model=AgentCommandResponse)
|
||||||
|
def post_event_logoff(
|
||||||
|
event_db_id: int,
|
||||||
|
body: LogoffActionBody,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> AgentCommandResponse:
|
||||||
|
event = db.get(Event, event_db_id)
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
cmd = execute_logoff_via_winrm(
|
||||||
|
db,
|
||||||
|
event,
|
||||||
|
session_id=body.session_id,
|
||||||
|
requested_by=user.username,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _agent_command_response(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{event_db_id}/actions/{command_uuid}", response_model=AgentCommandResponse)
|
||||||
|
def get_event_action_status(
|
||||||
|
event_db_id: int,
|
||||||
|
command_uuid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(get_current_user),
|
||||||
|
) -> AgentCommandResponse:
|
||||||
|
cmd = get_command_by_uuid(db, command_uuid)
|
||||||
|
if cmd is None or cmd.event_id != event_db_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Command not found")
|
||||||
|
return _agent_command_response(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{event_db_id}", response_model=EventDetail)
|
||||||
|
def get_event(
|
||||||
|
event_db_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
) -> EventDetail:
|
||||||
|
event = db.scalar(
|
||||||
|
select(Event).where(Event.id == event_db_id).options(joinedload(Event.host))
|
||||||
|
)
|
||||||
|
if event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
|
base = event_to_summary(event, db)
|
||||||
|
return EventDetail(
|
||||||
|
**base.model_dump(),
|
||||||
|
details=event.details,
|
||||||
|
raw=event.raw,
|
||||||
|
dedup_key=event.dedup_key,
|
||||||
|
correlation_id=event.correlation_id,
|
||||||
|
payload=event.payload,
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func, select, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Event
|
||||||
|
from app.services.host_health import count_stale_hosts
|
||||||
|
from app.version import APP_NAME, APP_VERSION
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
def build_health_payload(db: Session) -> dict:
|
||||||
|
db_status = "ok"
|
||||||
|
try:
|
||||||
|
db.execute(text("SELECT 1"))
|
||||||
|
except Exception:
|
||||||
|
db_status = "error"
|
||||||
|
|
||||||
|
hosts_stale = 0
|
||||||
|
last_event_received_at: str | None = None
|
||||||
|
if db_status == "ok":
|
||||||
|
settings = get_settings()
|
||||||
|
hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes)
|
||||||
|
last_received = db.scalar(select(func.max(Event.received_at)))
|
||||||
|
if last_received is not None:
|
||||||
|
last_event_received_at = last_received.isoformat()
|
||||||
|
|
||||||
|
overall = "ok" if db_status == "ok" else "degraded"
|
||||||
|
if db_status == "ok" and hosts_stale > 0:
|
||||||
|
overall = "degraded"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": overall,
|
||||||
|
"database": db_status,
|
||||||
|
"service": "security-alert-center",
|
||||||
|
"name": APP_NAME,
|
||||||
|
"version": APP_VERSION,
|
||||||
|
"hosts_stale": hosts_stale,
|
||||||
|
"last_event_received_at": last_event_received_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health(db: Session = Depends(get_db)) -> dict:
|
||||||
|
return build_health_payload(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/healthz")
|
||||||
|
def healthz(db: Session = Depends(get_db)) -> dict:
|
||||||
|
"""Kubernetes-style alias; same payload as /health."""
|
||||||
|
return build_health_payload(db)
|
||||||
@@ -0,0 +1,932 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import get_current_user, require_admin
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Event, Host
|
||||||
|
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
||||||
|
from app.utils.sql_like import escape_ilike_pattern
|
||||||
|
from app.services.agent_version import (
|
||||||
|
latest_agent_versions_by_product,
|
||||||
|
reference_agent_versions_by_product,
|
||||||
|
)
|
||||||
|
from app.services.agent_git_release import get_git_release_versions
|
||||||
|
from app.services.agent_update import (
|
||||||
|
AgentUpdateNotAllowedError,
|
||||||
|
host_version_status,
|
||||||
|
request_agent_update,
|
||||||
|
)
|
||||||
|
from app.services.host_remote_actions import (
|
||||||
|
RemoteActionAlreadyRunningError,
|
||||||
|
cancel_host_remote_action,
|
||||||
|
clear_stale_running_remote_actions,
|
||||||
|
get_remote_action_status,
|
||||||
|
run_agent_fallback_action,
|
||||||
|
run_ssh_monitor_update_action,
|
||||||
|
start_host_remote_action,
|
||||||
|
)
|
||||||
|
from app.services.agent_update_settings import (
|
||||||
|
PRODUCT_RDP,
|
||||||
|
PRODUCT_SSH,
|
||||||
|
get_effective_agent_update_config,
|
||||||
|
)
|
||||||
|
from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config
|
||||||
|
from app.services.host_sessions import (
|
||||||
|
HostSessionRow,
|
||||||
|
list_linux_sessions,
|
||||||
|
list_windows_sessions,
|
||||||
|
terminate_linux_session,
|
||||||
|
terminate_windows_session,
|
||||||
|
)
|
||||||
|
from app.services.host_delete import delete_host_and_related
|
||||||
|
from app.services.host_mgmt_credentials import (
|
||||||
|
get_host_mgmt_access_view,
|
||||||
|
upsert_host_mgmt_credentials,
|
||||||
|
)
|
||||||
|
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||||
|
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||||
|
from app.services.winrm_connect import (
|
||||||
|
HostNotWindowsError,
|
||||||
|
HostTargetMissingError,
|
||||||
|
WinAdminNotConfiguredError,
|
||||||
|
WinRmTestResult,
|
||||||
|
iter_winrm_targets,
|
||||||
|
test_winrm_connection,
|
||||||
|
)
|
||||||
|
from app.services.ssh_connect import (
|
||||||
|
HostNotLinuxError as SshHostNotLinuxError,
|
||||||
|
HostTargetMissingError as SshHostTargetMissingError,
|
||||||
|
LinuxAdminNotConfiguredError,
|
||||||
|
SshCommandResult,
|
||||||
|
iter_ssh_targets,
|
||||||
|
run_ssh_monitor_update,
|
||||||
|
test_ssh_connection,
|
||||||
|
)
|
||||||
|
from app.services.host_health import (
|
||||||
|
HEARTBEAT_TYPE,
|
||||||
|
agent_status as compute_agent_status,
|
||||||
|
apply_agent_status_host_filter,
|
||||||
|
max_daily_report_time_by_host,
|
||||||
|
max_event_time_by_host,
|
||||||
|
)
|
||||||
|
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/hosts", tags=["hosts"])
|
||||||
|
|
||||||
|
|
||||||
|
def _count_visible_events(db: Session, host_id: int, hidden: frozenset[str]) -> int:
|
||||||
|
stmt = select(func.count()).select_from(Event).where(Event.host_id == host_id)
|
||||||
|
type_filter = visibility_type_filter(hidden)
|
||||||
|
if type_filter is not None:
|
||||||
|
stmt = stmt.where(type_filter)
|
||||||
|
return int(db.scalar(stmt) or 0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=HostListResponse)
|
||||||
|
def list_hosts(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
product: str | None = None,
|
||||||
|
hostname: str | None = None,
|
||||||
|
agent_status: str | None = Query(None, pattern="^(online|stale|unknown)$"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
) -> HostListResponse:
|
||||||
|
stmt = select(Host)
|
||||||
|
count_stmt = select(func.count()).select_from(Host)
|
||||||
|
|
||||||
|
if product:
|
||||||
|
stmt = stmt.where(Host.product == product)
|
||||||
|
count_stmt = count_stmt.where(Host.product == product)
|
||||||
|
if hostname:
|
||||||
|
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||||
|
host_match = Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")
|
||||||
|
stmt = stmt.where(host_match)
|
||||||
|
count_stmt = count_stmt.where(host_match)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if agent_status:
|
||||||
|
stmt, count_stmt = apply_agent_status_host_filter(
|
||||||
|
stmt,
|
||||||
|
count_stmt,
|
||||||
|
agent_status,
|
||||||
|
stale_minutes=settings.sac_heartbeat_stale_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
total = db.scalar(count_stmt) or 0
|
||||||
|
rows = db.scalars(
|
||||||
|
stmt.order_by(Host.last_seen_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
||||||
|
report_map = max_daily_report_time_by_host(db)
|
||||||
|
latest_map = latest_agent_versions_by_product(db)
|
||||||
|
update_cfg = get_effective_agent_update_config(db)
|
||||||
|
git_release = get_git_release_versions(update_cfg, db=db)
|
||||||
|
reference_map = reference_agent_versions_by_product(
|
||||||
|
update_cfg,
|
||||||
|
latest_map,
|
||||||
|
git_versions=git_release.versions,
|
||||||
|
)
|
||||||
|
hidden = get_hidden_event_types(db)
|
||||||
|
|
||||||
|
items: list[HostSummary] = []
|
||||||
|
for host in rows:
|
||||||
|
event_count = _count_visible_events(db, host.id, hidden)
|
||||||
|
last_hb = hb_map.get(host.id)
|
||||||
|
items.append(
|
||||||
|
HostSummary(
|
||||||
|
id=host.id,
|
||||||
|
hostname=host.hostname,
|
||||||
|
display_name=host.display_name,
|
||||||
|
os_family=host.os_family,
|
||||||
|
os_version=host.os_version,
|
||||||
|
product=host.product,
|
||||||
|
product_version=host.product_version,
|
||||||
|
ipv4=host.ipv4,
|
||||||
|
last_seen_at=host.last_seen_at,
|
||||||
|
event_count=int(event_count or 0),
|
||||||
|
last_heartbeat_at=last_hb,
|
||||||
|
last_daily_report_at=report_map.get(host.id),
|
||||||
|
last_inventory_at=host.inventory_updated_at,
|
||||||
|
agent_status=compute_agent_status(
|
||||||
|
last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes
|
||||||
|
),
|
||||||
|
version_status=host_version_status(
|
||||||
|
host,
|
||||||
|
cfg=update_cfg,
|
||||||
|
latest_map=latest_map,
|
||||||
|
git_versions=git_release.versions,
|
||||||
|
),
|
||||||
|
pending_agent_update=bool(host.pending_agent_update),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return HostListResponse(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
latest_agent_versions=latest_map,
|
||||||
|
reference_agent_versions=reference_map,
|
||||||
|
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
|
||||||
|
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HostManualAddBody(BaseModel):
|
||||||
|
platform: Literal["windows", "linux"]
|
||||||
|
target: str = Field(..., min_length=1, max_length=253)
|
||||||
|
|
||||||
|
|
||||||
|
def _host_detail_from_model(
|
||||||
|
host: Host,
|
||||||
|
*,
|
||||||
|
db: Session,
|
||||||
|
settings,
|
||||||
|
) -> HostDetail:
|
||||||
|
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
||||||
|
report_map = max_daily_report_time_by_host(db)
|
||||||
|
hidden = get_hidden_event_types(db)
|
||||||
|
event_count = _count_visible_events(db, host.id, hidden)
|
||||||
|
last_hb = hb_map.get(host.id)
|
||||||
|
latest_map = latest_agent_versions_by_product(db)
|
||||||
|
update_cfg = get_effective_agent_update_config(db)
|
||||||
|
git_release = get_git_release_versions(update_cfg, db=db)
|
||||||
|
return HostDetail(
|
||||||
|
id=host.id,
|
||||||
|
hostname=host.hostname,
|
||||||
|
display_name=host.display_name,
|
||||||
|
os_family=host.os_family,
|
||||||
|
os_version=host.os_version,
|
||||||
|
product=host.product,
|
||||||
|
product_version=host.product_version,
|
||||||
|
ipv4=host.ipv4,
|
||||||
|
ipv6=host.ipv6,
|
||||||
|
last_seen_at=host.last_seen_at,
|
||||||
|
event_count=int(event_count or 0),
|
||||||
|
last_heartbeat_at=last_hb,
|
||||||
|
last_daily_report_at=report_map.get(host.id),
|
||||||
|
last_inventory_at=host.inventory_updated_at,
|
||||||
|
agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes),
|
||||||
|
version_status=host_version_status(
|
||||||
|
host,
|
||||||
|
cfg=update_cfg,
|
||||||
|
latest_map=latest_map,
|
||||||
|
git_versions=git_release.versions,
|
||||||
|
),
|
||||||
|
pending_agent_update=bool(host.pending_agent_update),
|
||||||
|
agent_instance_id=host.agent_instance_id,
|
||||||
|
tags=host.tags if isinstance(host.tags, list) else [],
|
||||||
|
use_sac_mode=host.use_sac_mode,
|
||||||
|
inventory=host.inventory if isinstance(host.inventory, dict) else None,
|
||||||
|
inventory_updated_at=host.inventory_updated_at,
|
||||||
|
created_at=host.created_at,
|
||||||
|
ssh_admin_ok=host.ssh_admin_ok,
|
||||||
|
ssh_admin_checked_at=host.ssh_admin_checked_at,
|
||||||
|
pending_update_requested_at=host.pending_update_requested_at,
|
||||||
|
pending_update_target_version=host.pending_update_target_version,
|
||||||
|
agent_config_revision=int(host.agent_config_revision or 0),
|
||||||
|
agent_config=get_host_agent_settings(host) or None,
|
||||||
|
agent_update_state=host.agent_update_state,
|
||||||
|
agent_update_last_at=host.agent_update_last_at,
|
||||||
|
agent_update_last_error=host.agent_update_last_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_ssh_admin_status(host: Host, ok: bool) -> None:
|
||||||
|
host.ssh_admin_ok = ok
|
||||||
|
host.ssh_admin_checked_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{host_id}", response_model=HostDetail)
|
||||||
|
def get_host(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
) -> HostDetail:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
settings = get_settings()
|
||||||
|
return _host_detail_from_model(host, db=db, settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
class HostDeleteResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
host_id: int
|
||||||
|
hostname: str
|
||||||
|
deleted_events: int
|
||||||
|
deleted_problems: int
|
||||||
|
|
||||||
|
|
||||||
|
class HostWinRmTestResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
target: str
|
||||||
|
hostname: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostSshActionResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
target: str
|
||||||
|
stdout: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
exit_code: int | None = None
|
||||||
|
product_version: str | None = None
|
||||||
|
ssh_admin_ok: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostAgentUpdateRequestResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
pending_agent_update: bool
|
||||||
|
target_version: str | None = None
|
||||||
|
agent_update_state: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostAgentConfigUpdate(BaseModel):
|
||||||
|
settings: dict[str, object] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class HostAgentConfigResponse(BaseModel):
|
||||||
|
config_revision: int
|
||||||
|
settings: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
class HostMgmtAccessResponse(BaseModel):
|
||||||
|
host_id: int
|
||||||
|
has_override: bool
|
||||||
|
user: str | None = None
|
||||||
|
password_set: bool = False
|
||||||
|
password_hint: str | None = None
|
||||||
|
effective_source: str
|
||||||
|
effective_configured: bool
|
||||||
|
effective_user: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostMgmtAccessUpdate(BaseModel):
|
||||||
|
user: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
clear: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class HostRemoteActionStartResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
host_id: int
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/manual-add",
|
||||||
|
response_model=HostRemoteActionStartResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
def post_host_manual_add(
|
||||||
|
body: HostManualAddBody,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostRemoteActionStartResponse:
|
||||||
|
from app.services.host_manual_add import ManualHostAddError, prepare_manual_host_add
|
||||||
|
|
||||||
|
try:
|
||||||
|
host = prepare_manual_host_add(db, platform=body.platform, target=body.target)
|
||||||
|
except ManualHostAddError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
if body.platform == "windows":
|
||||||
|
title = f"Ручное добавление Windows: {host.hostname}"
|
||||||
|
else:
|
||||||
|
title = f"Ручное добавление Linux: {host.hostname}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_host_remote_action(
|
||||||
|
db,
|
||||||
|
host,
|
||||||
|
title=title,
|
||||||
|
runner=run_agent_fallback_action,
|
||||||
|
)
|
||||||
|
except RemoteActionAlreadyRunningError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return HostRemoteActionStartResponse(
|
||||||
|
status="running",
|
||||||
|
host_id=host.id,
|
||||||
|
title=title,
|
||||||
|
message="Проверка пройдена, установка агента запущена на сервере SAC",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HostRemoteActionJobResponse(BaseModel):
|
||||||
|
active: bool
|
||||||
|
host_id: int
|
||||||
|
hostname: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
title: str = ""
|
||||||
|
message: str = ""
|
||||||
|
stdout: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
output: str | None = None
|
||||||
|
ok: bool | None = None
|
||||||
|
exit_code: int | None = None
|
||||||
|
product_version: str | None = None
|
||||||
|
target: str | None = None
|
||||||
|
agent_update_state: str | None = None
|
||||||
|
started_at: str | None = None
|
||||||
|
finished_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostSessionItem(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
user: str
|
||||||
|
tty: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
source_ip: str | None = None
|
||||||
|
session_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostSessionsResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
target: str | None = None
|
||||||
|
sessions: list[HostSessionItem]
|
||||||
|
|
||||||
|
|
||||||
|
class HostSessionTerminateBody(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class HostSessionTerminateResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
target: str | None = None
|
||||||
|
stdout: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _session_items(rows: list[HostSessionRow]) -> list[HostSessionItem]:
|
||||||
|
return [
|
||||||
|
HostSessionItem(
|
||||||
|
session_id=r.session_id,
|
||||||
|
user=r.user,
|
||||||
|
tty=r.tty,
|
||||||
|
state=r.state,
|
||||||
|
source_ip=r.source_ip,
|
||||||
|
session_name=r.session_name,
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_action_response(
|
||||||
|
result: SshCommandResult,
|
||||||
|
*,
|
||||||
|
attempts: list[str] | None = None,
|
||||||
|
ssh_admin_ok: bool | None = None,
|
||||||
|
) -> HostSshActionResponse:
|
||||||
|
message = result.message
|
||||||
|
if attempts:
|
||||||
|
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||||
|
return HostSshActionResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
message=message,
|
||||||
|
target=result.target,
|
||||||
|
stdout=result.stdout or None,
|
||||||
|
stderr=result.stderr or None,
|
||||||
|
exit_code=result.exit_code,
|
||||||
|
product_version=result.agent_version,
|
||||||
|
ssh_admin_ok=ssh_admin_ok,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ssh_action_on_host(
|
||||||
|
host: Host,
|
||||||
|
cfg,
|
||||||
|
*,
|
||||||
|
action,
|
||||||
|
) -> HostSshActionResponse:
|
||||||
|
try:
|
||||||
|
targets = iter_ssh_targets(host)
|
||||||
|
except SshHostNotLinuxError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except SshHostTargetMissingError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
last_result: SshCommandResult | None = None
|
||||||
|
attempts: list[str] = []
|
||||||
|
for target in targets:
|
||||||
|
try:
|
||||||
|
result = action(target=target, user=cfg.user, password=cfg.password)
|
||||||
|
except LinuxAdminNotConfiguredError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
last_result = result
|
||||||
|
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
|
||||||
|
if result.ok:
|
||||||
|
return _ssh_action_response(result, attempts=attempts if len(attempts) > 1 else None)
|
||||||
|
|
||||||
|
assert last_result is not None
|
||||||
|
return _ssh_action_response(last_result, attempts=attempts if len(attempts) > 1 else None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse)
|
||||||
|
def test_host_winrm(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostWinRmTestResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
|
cfg = get_effective_win_admin_for_host(db, host)
|
||||||
|
if not cfg.configured:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
targets = iter_winrm_targets(host)
|
||||||
|
except HostNotWindowsError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except HostTargetMissingError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
last_result = None
|
||||||
|
attempts: list[str] = []
|
||||||
|
for target in targets:
|
||||||
|
try:
|
||||||
|
result = test_winrm_connection(
|
||||||
|
target=target,
|
||||||
|
user=cfg.user,
|
||||||
|
password=cfg.password,
|
||||||
|
)
|
||||||
|
except WinAdminNotConfiguredError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
last_result = result
|
||||||
|
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
|
||||||
|
if result.ok:
|
||||||
|
if len(targets) > 1:
|
||||||
|
result = WinRmTestResult(
|
||||||
|
ok=True,
|
||||||
|
message=f"{result.message} (пробовали: {', '.join(attempts)})",
|
||||||
|
target=result.target,
|
||||||
|
hostname=result.hostname,
|
||||||
|
)
|
||||||
|
return HostWinRmTestResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
message=result.message,
|
||||||
|
target=result.target,
|
||||||
|
hostname=result.hostname,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert last_result is not None
|
||||||
|
message = last_result.message
|
||||||
|
if len(attempts) > 1:
|
||||||
|
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||||
|
return HostWinRmTestResponse(
|
||||||
|
ok=False,
|
||||||
|
message=message,
|
||||||
|
target=last_result.target,
|
||||||
|
hostname=last_result.hostname,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{host_id}/actions/ssh-test", response_model=HostSshActionResponse)
|
||||||
|
def test_host_ssh(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostSshActionResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
|
cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
|
if not cfg.configured:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
|
||||||
|
_set_ssh_admin_status(host, response.ok)
|
||||||
|
db.commit()
|
||||||
|
return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{host_id}/actions/agent-update",
|
||||||
|
response_model=HostRemoteActionStartResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
def update_host_agent_via_ssh(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostRemoteActionStartResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
|
cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
|
if not cfg.configured:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||||
|
)
|
||||||
|
|
||||||
|
title = "Обновление ssh-monitor (SSH)"
|
||||||
|
try:
|
||||||
|
start_host_remote_action(
|
||||||
|
db,
|
||||||
|
host,
|
||||||
|
title=title,
|
||||||
|
runner=run_ssh_monitor_update_action,
|
||||||
|
poll_remote_log=True,
|
||||||
|
)
|
||||||
|
except RemoteActionAlreadyRunningError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
return HostRemoteActionStartResponse(
|
||||||
|
status="running",
|
||||||
|
host_id=host.id,
|
||||||
|
title=title,
|
||||||
|
message="Обновление запущено на сервере SAC и продолжится в фоне",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{host_id}/actions/request-agent-update", response_model=HostAgentUpdateRequestResponse)
|
||||||
|
def post_request_agent_update(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostAgentUpdateRequestResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
try:
|
||||||
|
request_agent_update(db, host)
|
||||||
|
except AgentUpdateNotAllowedError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
db.commit()
|
||||||
|
return HostAgentUpdateRequestResponse(
|
||||||
|
status="requested",
|
||||||
|
pending_agent_update=host.pending_agent_update,
|
||||||
|
target_version=host.pending_update_target_version,
|
||||||
|
agent_update_state=host.agent_update_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{host_id}/actions/agent-update-fallback",
|
||||||
|
response_model=HostRemoteActionStartResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
def post_agent_update_fallback(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostRemoteActionStartResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||||
|
from app.services.ssh_connect import is_linux_host
|
||||||
|
from app.services.winrm_connect import is_windows_host
|
||||||
|
|
||||||
|
product = (host.product or "").strip()
|
||||||
|
if product == PRODUCT_RDP or is_windows_host(host):
|
||||||
|
title = "Обновление через WinRM"
|
||||||
|
elif product == PRODUCT_SSH or is_linux_host(host):
|
||||||
|
title = "Fallback SSH (ssh-monitor)"
|
||||||
|
else:
|
||||||
|
title = "Обновление агента (fallback)"
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_host_remote_action(
|
||||||
|
db,
|
||||||
|
host,
|
||||||
|
title=title,
|
||||||
|
runner=run_agent_fallback_action,
|
||||||
|
)
|
||||||
|
except RemoteActionAlreadyRunningError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
return HostRemoteActionStartResponse(
|
||||||
|
status="running",
|
||||||
|
host_id=host.id,
|
||||||
|
title=title,
|
||||||
|
message="Обновление запущено на сервере SAC и продолжится в фоне",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{host_id}/actions/remote-job", response_model=HostRemoteActionJobResponse)
|
||||||
|
def get_host_remote_job(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostRemoteActionJobResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
db.refresh(host)
|
||||||
|
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{host_id}/actions/remote-job/cancel", response_model=HostRemoteActionJobResponse)
|
||||||
|
def cancel_host_remote_job(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostRemoteActionJobResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
if not cancel_host_remote_action(db, host):
|
||||||
|
raise HTTPException(status_code=409, detail="No running remote action for this host")
|
||||||
|
db.refresh(host)
|
||||||
|
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{host_id}/actions/sessions/list", response_model=HostSessionsResponse)
|
||||||
|
def list_host_sessions(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostSessionsResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
|
from app.services.ssh_connect import is_linux_host
|
||||||
|
from app.services.winrm_connect import is_windows_host
|
||||||
|
|
||||||
|
if is_linux_host(host):
|
||||||
|
cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
|
if not cfg.configured:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
sessions, result = list_linux_sessions(host, cfg)
|
||||||
|
except SshHostNotLinuxError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except SshHostTargetMissingError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return HostSessionsResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
message=result.message,
|
||||||
|
target=result.target or None,
|
||||||
|
sessions=_session_items(sessions),
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_windows_host(host):
|
||||||
|
cfg = get_effective_win_admin_for_host(db, host)
|
||||||
|
if not cfg.configured:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
sessions, result = list_windows_sessions(host, cfg)
|
||||||
|
except HostNotWindowsError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except HostTargetMissingError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=502, detail="WinRM qwinsta failed")
|
||||||
|
return HostSessionsResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
message=result.message,
|
||||||
|
target=result.target or None,
|
||||||
|
sessions=_session_items(sessions),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="Host OS does not support live sessions")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{host_id}/actions/sessions/terminate", response_model=HostSessionTerminateResponse)
|
||||||
|
def terminate_host_session(
|
||||||
|
host_id: int,
|
||||||
|
body: HostSessionTerminateBody,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostSessionTerminateResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
|
from app.services.ssh_connect import is_linux_host
|
||||||
|
from app.services.winrm_connect import is_windows_host
|
||||||
|
|
||||||
|
sid = body.session_id.strip()
|
||||||
|
if not sid:
|
||||||
|
raise HTTPException(status_code=422, detail="session_id is required")
|
||||||
|
|
||||||
|
if is_linux_host(host):
|
||||||
|
cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
|
if not cfg.configured:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = terminate_linux_session(host, cfg, sid)
|
||||||
|
except SshHostNotLinuxError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except SshHostTargetMissingError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return HostSessionTerminateResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
message=result.message,
|
||||||
|
target=result.target or None,
|
||||||
|
stdout=result.stdout or None,
|
||||||
|
stderr=result.stderr or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_windows_host(host):
|
||||||
|
cfg = get_effective_win_admin_for_host(db, host)
|
||||||
|
if not cfg.configured:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = terminate_windows_session(host, cfg, sid)
|
||||||
|
except HostNotWindowsError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=502, detail="WinRM logoff failed")
|
||||||
|
return HostSessionTerminateResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
message=result.message,
|
||||||
|
target=result.target or None,
|
||||||
|
stdout=result.stdout or None,
|
||||||
|
stderr=result.stderr or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="Host OS does not support session terminate")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||||
|
def patch_host_agent_config(
|
||||||
|
host_id: int,
|
||||||
|
body: HostAgentConfigUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostAgentConfigResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
try:
|
||||||
|
update_host_agent_config(db, host, body.settings)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
db.commit()
|
||||||
|
settings = get_host_agent_settings(host)
|
||||||
|
return HostAgentConfigResponse(
|
||||||
|
config_revision=int(host.agent_config_revision or 0),
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{host_id}/access", response_model=HostMgmtAccessResponse)
|
||||||
|
def get_host_access(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostMgmtAccessResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
view = get_host_mgmt_access_view(db, host)
|
||||||
|
return HostMgmtAccessResponse(
|
||||||
|
host_id=view.host_id,
|
||||||
|
has_override=view.has_override,
|
||||||
|
user=view.user,
|
||||||
|
password_set=view.password_set,
|
||||||
|
password_hint=view.password_hint,
|
||||||
|
effective_source=view.effective_source,
|
||||||
|
effective_configured=view.effective_configured,
|
||||||
|
effective_user=view.effective_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{host_id}/access", response_model=HostMgmtAccessResponse)
|
||||||
|
def put_host_access(
|
||||||
|
host_id: int,
|
||||||
|
body: HostMgmtAccessUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostMgmtAccessResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
view = upsert_host_mgmt_credentials(
|
||||||
|
db,
|
||||||
|
host,
|
||||||
|
user=body.user,
|
||||||
|
password=body.password,
|
||||||
|
clear=body.clear,
|
||||||
|
)
|
||||||
|
return HostMgmtAccessResponse(
|
||||||
|
host_id=view.host_id,
|
||||||
|
has_override=view.has_override,
|
||||||
|
user=view.user,
|
||||||
|
password_set=view.password_set,
|
||||||
|
password_hint=view.password_hint,
|
||||||
|
effective_source=view.effective_source,
|
||||||
|
effective_configured=view.effective_configured,
|
||||||
|
effective_user=view.effective_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||||
|
def get_host_agent_config(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostAgentConfigResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
settings = get_host_agent_settings(host)
|
||||||
|
return HostAgentConfigResponse(
|
||||||
|
config_revision=int(host.agent_config_revision or 0),
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{host_id}", response_model=HostDeleteResponse)
|
||||||
|
def delete_host(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostDeleteResponse:
|
||||||
|
result = delete_host_and_related(db, host_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
db.commit()
|
||||||
|
return HostDeleteResponse(
|
||||||
|
status="deleted",
|
||||||
|
host_id=result.host_id,
|
||||||
|
hostname=result.hostname,
|
||||||
|
deleted_events=result.deleted_events,
|
||||||
|
deleted_problems=result.deleted_problems,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import create_access_token, get_current_user, require_admin, CurrentUser
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.mobile_settings import LOGIN_MODES
|
||||||
|
from app.services.mobile_devices import (
|
||||||
|
get_device_or_raise,
|
||||||
|
list_mobile_devices,
|
||||||
|
revoke_device,
|
||||||
|
delete_device_record,
|
||||||
|
touch_device,
|
||||||
|
update_fcm_token,
|
||||||
|
assert_device_active,
|
||||||
|
)
|
||||||
|
from app.services.mobile_enrollment import (
|
||||||
|
create_enrollment_code,
|
||||||
|
enroll_device,
|
||||||
|
list_enrollment_codes,
|
||||||
|
revoke_enrollment_code,
|
||||||
|
)
|
||||||
|
from app.services.mobile_notify import (
|
||||||
|
MobileNotConfiguredError,
|
||||||
|
MobileSendError,
|
||||||
|
get_effective_fcm_config,
|
||||||
|
send_test_push,
|
||||||
|
)
|
||||||
|
from app.services.mobile_settings import upsert_mobile_settings, get_effective_mobile_settings
|
||||||
|
from app.services.mobile_tokens import find_valid_refresh_token, revoke_refresh_tokens_for_device, store_refresh_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/mobile", tags=["mobile"])
|
||||||
|
|
||||||
|
|
||||||
|
class MobileSettingsResponse(BaseModel):
|
||||||
|
devices_allowed: bool
|
||||||
|
max_devices_per_user: int
|
||||||
|
min_app_version: str | None = None
|
||||||
|
fcm_enabled: bool
|
||||||
|
fcm_configured: bool
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
class MobileSettingsUpdate(BaseModel):
|
||||||
|
devices_allowed: bool
|
||||||
|
max_devices_per_user: int = Field(ge=1, le=50)
|
||||||
|
min_app_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollmentCodeCreate(BaseModel):
|
||||||
|
label: str = ""
|
||||||
|
target_user_id: int | None = None
|
||||||
|
login_mode: str = "password"
|
||||||
|
max_uses: int = Field(default=1, ge=1, le=100)
|
||||||
|
expires_in_hours: int = Field(default=24, ge=1, le=720)
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollmentCodeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
label: str
|
||||||
|
code_prefix: str
|
||||||
|
target_user_id: int | None
|
||||||
|
target_username: str | None
|
||||||
|
login_mode: str
|
||||||
|
max_uses: int
|
||||||
|
use_count: int
|
||||||
|
expires_at: datetime
|
||||||
|
revoked_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollmentCodeCreatedResponse(EnrollmentCodeResponse):
|
||||||
|
enrollment_code: str = Field(description="Показывается один раз при создании")
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDeviceResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
device_uuid: str
|
||||||
|
display_name: str
|
||||||
|
platform: str
|
||||||
|
app_version: str | None
|
||||||
|
has_fcm_token: bool
|
||||||
|
enrolled_at: datetime
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
revoked_at: datetime | None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollRequest(BaseModel):
|
||||||
|
enrollment_code: str
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
device_uuid: str
|
||||||
|
display_name: str = "Android"
|
||||||
|
platform: str = "android"
|
||||||
|
app_version: str | None = None
|
||||||
|
fcm_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
device_id: int
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
device_uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
class FcmTokenUpdate(BaseModel):
|
||||||
|
fcm_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class TestPushResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_response(db: Session) -> MobileSettingsResponse:
|
||||||
|
cfg = get_effective_mobile_settings(db)
|
||||||
|
fcm = get_effective_fcm_config()
|
||||||
|
return MobileSettingsResponse(
|
||||||
|
devices_allowed=cfg.devices_allowed,
|
||||||
|
max_devices_per_user=cfg.max_devices_per_user,
|
||||||
|
min_app_version=cfg.min_app_version,
|
||||||
|
fcm_enabled=fcm.enabled,
|
||||||
|
fcm_configured=fcm.configured,
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _code_to_response(item) -> EnrollmentCodeResponse:
|
||||||
|
return EnrollmentCodeResponse(
|
||||||
|
id=item.id,
|
||||||
|
label=item.label,
|
||||||
|
code_prefix=item.code_prefix,
|
||||||
|
target_user_id=item.target_user_id,
|
||||||
|
target_username=item.target_username,
|
||||||
|
login_mode=item.login_mode,
|
||||||
|
max_uses=item.max_uses,
|
||||||
|
use_count=item.use_count,
|
||||||
|
expires_at=item.expires_at,
|
||||||
|
revoked_at=item.revoked_at,
|
||||||
|
created_at=item.created_at,
|
||||||
|
is_active=item.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _device_to_response(item) -> MobileDeviceResponse:
|
||||||
|
return MobileDeviceResponse(
|
||||||
|
id=item.id,
|
||||||
|
user_id=item.user_id,
|
||||||
|
username=item.username,
|
||||||
|
device_uuid=item.device_uuid,
|
||||||
|
display_name=item.display_name,
|
||||||
|
platform=item.platform,
|
||||||
|
app_version=item.app_version,
|
||||||
|
has_fcm_token=item.has_fcm_token,
|
||||||
|
enrolled_at=item.enrolled_at,
|
||||||
|
last_seen_at=item.last_seen_at,
|
||||||
|
revoked_at=item.revoked_at,
|
||||||
|
is_active=item.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_user_id(db: Session, username: str) -> int | None:
|
||||||
|
user = db.scalar(select(User).where(func.lower(User.username) == username.lower()))
|
||||||
|
return user.id if user else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_model=MobileSettingsResponse)
|
||||||
|
def get_mobile_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> MobileSettingsResponse:
|
||||||
|
return _settings_response(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings", response_model=MobileSettingsResponse)
|
||||||
|
def update_mobile_settings(
|
||||||
|
body: MobileSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> MobileSettingsResponse:
|
||||||
|
try:
|
||||||
|
upsert_mobile_settings(
|
||||||
|
db,
|
||||||
|
devices_allowed=body.devices_allowed,
|
||||||
|
max_devices_per_user=body.max_devices_per_user,
|
||||||
|
min_app_version=body.min_app_version,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
return _settings_response(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enrollment-codes", response_model=list[EnrollmentCodeResponse])
|
||||||
|
def get_enrollment_codes(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> list[EnrollmentCodeResponse]:
|
||||||
|
return [_code_to_response(item) for item in list_enrollment_codes(db)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enrollment-codes", response_model=EnrollmentCodeCreatedResponse)
|
||||||
|
def post_enrollment_code(
|
||||||
|
body: EnrollmentCodeCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_admin),
|
||||||
|
) -> EnrollmentCodeCreatedResponse:
|
||||||
|
if body.login_mode not in LOGIN_MODES:
|
||||||
|
raise HTTPException(status_code=422, detail=f"login_mode must be one of: {sorted(LOGIN_MODES)}")
|
||||||
|
try:
|
||||||
|
summary, plaintext = create_enrollment_code(
|
||||||
|
db,
|
||||||
|
created_by_user_id=_admin_user_id(db, user.username),
|
||||||
|
label=body.label,
|
||||||
|
target_user_id=body.target_user_id,
|
||||||
|
login_mode=body.login_mode,
|
||||||
|
max_uses=body.max_uses,
|
||||||
|
expires_in_hours=body.expires_in_hours,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
base = _code_to_response(summary)
|
||||||
|
return EnrollmentCodeCreatedResponse(**base.model_dump(), enrollment_code=plaintext)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/enrollment-codes/{code_id}", status_code=204)
|
||||||
|
def delete_enrollment_code(
|
||||||
|
code_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
revoke_enrollment_code(db, code_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices", response_model=list[MobileDeviceResponse])
|
||||||
|
def get_devices(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> list[MobileDeviceResponse]:
|
||||||
|
return [_device_to_response(item) for item in list_mobile_devices(db)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{device_id}/revoke", response_model=MobileDeviceResponse)
|
||||||
|
def post_revoke_device(
|
||||||
|
device_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> MobileDeviceResponse:
|
||||||
|
try:
|
||||||
|
summary = revoke_device(db, device_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return _device_to_response(summary)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/devices/{device_id}", status_code=204)
|
||||||
|
def delete_device(
|
||||||
|
device_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
delete_device_record(db, device_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{device_id}/test-push", response_model=TestPushResponse)
|
||||||
|
def test_device_push(
|
||||||
|
device_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> TestPushResponse:
|
||||||
|
try:
|
||||||
|
send_test_push(db=db, device_id=device_id)
|
||||||
|
except MobileNotConfiguredError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except MobileSendError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
return TestPushResponse(message="Тестовое push отправлено на устройство")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enroll", response_model=EnrollResponse)
|
||||||
|
def post_enroll(body: EnrollRequest, db: Session = Depends(get_db)) -> EnrollResponse:
|
||||||
|
try:
|
||||||
|
result = enroll_device(
|
||||||
|
db,
|
||||||
|
enrollment_code=body.enrollment_code,
|
||||||
|
username=body.username,
|
||||||
|
password=body.password,
|
||||||
|
device_uuid=body.device_uuid,
|
||||||
|
display_name=body.display_name,
|
||||||
|
platform=body.platform,
|
||||||
|
app_version=body.app_version,
|
||||||
|
fcm_token=body.fcm_token,
|
||||||
|
create_access_token=create_access_token,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return EnrollResponse(
|
||||||
|
access_token=result.access_token,
|
||||||
|
refresh_token=result.refresh_token,
|
||||||
|
device_id=result.device_id,
|
||||||
|
username=result.username,
|
||||||
|
role=result.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/refresh", response_model=EnrollResponse)
|
||||||
|
def post_refresh(body: RefreshRequest, db: Session = Depends(get_db)) -> EnrollResponse:
|
||||||
|
token_row = find_valid_refresh_token(db, body.refresh_token.strip())
|
||||||
|
if token_row is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||||
|
device = get_device_or_raise(db, token_row.device_id)
|
||||||
|
try:
|
||||||
|
assert_device_active(device)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||||
|
if device.device_uuid != body.device_uuid.strip():
|
||||||
|
raise HTTPException(status_code=401, detail="device_uuid mismatch")
|
||||||
|
|
||||||
|
user = db.get(User, device.user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, detail="User inactive")
|
||||||
|
|
||||||
|
revoke_refresh_tokens_for_device(db, device.id)
|
||||||
|
refresh_plain = store_refresh_token(db, device_id=device.id)
|
||||||
|
touch_device(db, device)
|
||||||
|
access_token = create_access_token(user.username, user.role, device_id=device.id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return EnrollResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_plain,
|
||||||
|
device_id=device.id,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/devices/me/fcm", status_code=204)
|
||||||
|
def put_my_fcm_token(
|
||||||
|
body: FcmTokenUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
if user.device_id is None:
|
||||||
|
raise HTTPException(status_code=403, detail="Mobile device token required")
|
||||||
|
device = get_device_or_raise(db, user.device_id)
|
||||||
|
try:
|
||||||
|
assert_device_active(device)
|
||||||
|
update_fcm_token(db, device, body.fcm_token)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import get_current_user
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
from app.models import Event, Host, Problem, ProblemEvent
|
||||||
|
from app.services.event_type_visibility import get_hidden_event_types
|
||||||
|
from app.utils.sql_like import escape_ilike_pattern
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/problems", tags=["problems"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ProblemSummary(BaseModel):
|
||||||
|
|
||||||
|
id: int
|
||||||
|
|
||||||
|
host_id: int | None
|
||||||
|
|
||||||
|
hostname: str | None
|
||||||
|
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
title: str
|
||||||
|
|
||||||
|
summary: str
|
||||||
|
|
||||||
|
severity: str
|
||||||
|
|
||||||
|
status: str
|
||||||
|
|
||||||
|
rule_id: str | None
|
||||||
|
|
||||||
|
fingerprint: str
|
||||||
|
|
||||||
|
event_count: int
|
||||||
|
|
||||||
|
last_seen_at: datetime
|
||||||
|
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ProblemListResponse(BaseModel):
|
||||||
|
|
||||||
|
items: list[ProblemSummary]
|
||||||
|
|
||||||
|
total: int
|
||||||
|
|
||||||
|
page: int
|
||||||
|
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ProblemEventItem(BaseModel):
|
||||||
|
|
||||||
|
id: int
|
||||||
|
|
||||||
|
event_id: str
|
||||||
|
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
type: str
|
||||||
|
|
||||||
|
severity: str
|
||||||
|
|
||||||
|
title: str
|
||||||
|
|
||||||
|
summary: str
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ProblemDetail(ProblemSummary):
|
||||||
|
|
||||||
|
events: list[ProblemEventItem]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _problem_summary(p: Problem) -> ProblemSummary:
|
||||||
|
|
||||||
|
return ProblemSummary(
|
||||||
|
|
||||||
|
id=p.id,
|
||||||
|
|
||||||
|
host_id=p.host_id,
|
||||||
|
|
||||||
|
hostname=p.host.hostname if p.host else None,
|
||||||
|
|
||||||
|
display_name=p.host.display_name if p.host else None,
|
||||||
|
|
||||||
|
title=p.title,
|
||||||
|
|
||||||
|
summary=p.summary,
|
||||||
|
|
||||||
|
severity=p.severity,
|
||||||
|
|
||||||
|
status=p.status,
|
||||||
|
|
||||||
|
rule_id=p.rule_id,
|
||||||
|
|
||||||
|
fingerprint=p.fingerprint,
|
||||||
|
|
||||||
|
event_count=p.event_count,
|
||||||
|
|
||||||
|
last_seen_at=p.last_seen_at,
|
||||||
|
|
||||||
|
created_at=p.created_at,
|
||||||
|
|
||||||
|
updated_at=p.updated_at,
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ProblemListResponse)
|
||||||
|
|
||||||
|
def list_problems(
|
||||||
|
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
|
||||||
|
status: str | None = Query(None),
|
||||||
|
|
||||||
|
severity: str | None = Query(None),
|
||||||
|
|
||||||
|
host_id: int | None = Query(None),
|
||||||
|
|
||||||
|
hostname: str | None = Query(None),
|
||||||
|
|
||||||
|
created_within_hours: int | None = Query(None, ge=1, le=8760),
|
||||||
|
|
||||||
|
resolved_within_hours: int | None = Query(None, ge=1, le=8760),
|
||||||
|
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
|
||||||
|
) -> ProblemListResponse:
|
||||||
|
|
||||||
|
stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host))
|
||||||
|
|
||||||
|
count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if status:
|
||||||
|
|
||||||
|
stmt = stmt.where(Problem.status == status)
|
||||||
|
|
||||||
|
count_stmt = count_stmt.where(Problem.status == status)
|
||||||
|
|
||||||
|
if severity:
|
||||||
|
|
||||||
|
stmt = stmt.where(Problem.severity == severity)
|
||||||
|
|
||||||
|
count_stmt = count_stmt.where(Problem.severity == severity)
|
||||||
|
|
||||||
|
if host_id is not None:
|
||||||
|
|
||||||
|
stmt = stmt.where(Problem.host_id == host_id)
|
||||||
|
|
||||||
|
count_stmt = count_stmt.where(Problem.host_id == host_id)
|
||||||
|
|
||||||
|
if hostname:
|
||||||
|
|
||||||
|
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||||
|
|
||||||
|
stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||||
|
|
||||||
|
count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||||
|
|
||||||
|
if created_within_hours is not None:
|
||||||
|
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=created_within_hours)
|
||||||
|
|
||||||
|
stmt = stmt.where(Problem.created_at >= since, Problem.status != "resolved")
|
||||||
|
|
||||||
|
count_stmt = count_stmt.where(Problem.created_at >= since, Problem.status != "resolved")
|
||||||
|
|
||||||
|
if resolved_within_hours is not None:
|
||||||
|
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=resolved_within_hours)
|
||||||
|
|
||||||
|
stmt = stmt.where(Problem.status == "resolved", Problem.updated_at >= since)
|
||||||
|
|
||||||
|
count_stmt = count_stmt.where(Problem.status == "resolved", Problem.updated_at >= since)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
total = db.scalar(count_stmt) or 0
|
||||||
|
|
||||||
|
rows = db.scalars(
|
||||||
|
|
||||||
|
stmt.order_by(Problem.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||||
|
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
items = [_problem_summary(p) for p in rows]
|
||||||
|
|
||||||
|
return ProblemListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{problem_id}", response_model=ProblemDetail)
|
||||||
|
|
||||||
|
def get_problem(
|
||||||
|
|
||||||
|
problem_id: int,
|
||||||
|
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
|
||||||
|
) -> ProblemDetail:
|
||||||
|
|
||||||
|
problem = db.scalar(
|
||||||
|
|
||||||
|
select(Problem).where(Problem.id == problem_id).options(joinedload(Problem.host))
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
if problem is None:
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="Problem not found")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
event_rows = db.scalars(
|
||||||
|
|
||||||
|
select(Event)
|
||||||
|
|
||||||
|
.join(ProblemEvent, ProblemEvent.event_id == Event.id)
|
||||||
|
|
||||||
|
.where(ProblemEvent.problem_id == problem_id)
|
||||||
|
|
||||||
|
.order_by(Event.occurred_at.desc())
|
||||||
|
|
||||||
|
).all()
|
||||||
|
hidden = get_hidden_event_types(db)
|
||||||
|
event_rows = [event for event in event_rows if event.type not in hidden]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
base = _problem_summary(problem)
|
||||||
|
|
||||||
|
return ProblemDetail(
|
||||||
|
|
||||||
|
**base.model_dump(),
|
||||||
|
|
||||||
|
events=[
|
||||||
|
|
||||||
|
ProblemEventItem(
|
||||||
|
|
||||||
|
id=e.id,
|
||||||
|
|
||||||
|
event_id=e.event_id,
|
||||||
|
|
||||||
|
occurred_at=e.occurred_at,
|
||||||
|
|
||||||
|
type=e.type,
|
||||||
|
|
||||||
|
severity=e.severity,
|
||||||
|
|
||||||
|
title=e.title,
|
||||||
|
|
||||||
|
summary=e.summary,
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
for e in event_rows
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{problem_id}/ack")
|
||||||
|
|
||||||
|
def ack_problem(
|
||||||
|
|
||||||
|
problem_id: int,
|
||||||
|
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
|
||||||
|
) -> dict:
|
||||||
|
|
||||||
|
problem = db.get(Problem, problem_id)
|
||||||
|
|
||||||
|
if problem is None:
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="Problem not found")
|
||||||
|
|
||||||
|
if problem.status == "resolved":
|
||||||
|
|
||||||
|
raise HTTPException(status_code=409, detail="Problem already resolved")
|
||||||
|
|
||||||
|
problem.status = "acknowledged"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"id": problem.id, "status": problem.status}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{problem_id}/resolve")
|
||||||
|
|
||||||
|
def resolve_problem(
|
||||||
|
|
||||||
|
problem_id: int,
|
||||||
|
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
|
||||||
|
) -> dict:
|
||||||
|
|
||||||
|
problem = db.get(Problem, problem_id)
|
||||||
|
|
||||||
|
if problem is None:
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="Problem not found")
|
||||||
|
|
||||||
|
if problem.status == "resolved":
|
||||||
|
|
||||||
|
raise HTTPException(status_code=409, detail="Problem already resolved")
|
||||||
|
|
||||||
|
problem.status = "resolved"
|
||||||
|
problem.resolved_by = "manual"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"id": problem.id, "status": problem.status}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import agent, auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(health.router)
|
||||||
|
api_router.include_router(auth.router)
|
||||||
|
api_router.include_router(agent.router)
|
||||||
|
api_router.include_router(events.router)
|
||||||
|
api_router.include_router(hosts.router)
|
||||||
|
api_router.include_router(problems.router)
|
||||||
|
api_router.include_router(dashboards.router)
|
||||||
|
api_router.include_router(settings.router)
|
||||||
|
api_router.include_router(system.router)
|
||||||
|
api_router.include_router(users.router)
|
||||||
|
api_router.include_router(stream.router)
|
||||||
|
api_router.include_router(mobile.router)
|
||||||
@@ -0,0 +1,860 @@
|
|||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import require_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message
|
||||||
|
from app.services.notification_policy import (
|
||||||
|
NotificationPolicyConfig,
|
||||||
|
get_effective_notification_policy,
|
||||||
|
upsert_notification_policy,
|
||||||
|
)
|
||||||
|
from app.services.notification_settings import (
|
||||||
|
VALID_SEVERITIES,
|
||||||
|
EmailConfig,
|
||||||
|
TelegramConfig,
|
||||||
|
WebhookConfig,
|
||||||
|
get_effective_email_config,
|
||||||
|
get_effective_telegram_config,
|
||||||
|
get_effective_webhook_config,
|
||||||
|
upsert_email_channel,
|
||||||
|
upsert_telegram_channel,
|
||||||
|
upsert_webhook_channel,
|
||||||
|
)
|
||||||
|
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
||||||
|
from app.services.webhook_notify import (
|
||||||
|
WebhookNotConfiguredError,
|
||||||
|
WebhookSendError,
|
||||||
|
send_webhook_test_message,
|
||||||
|
)
|
||||||
|
from app.services.event_severity_overrides import (
|
||||||
|
SOURCE_DB,
|
||||||
|
list_severity_override_items,
|
||||||
|
replace_severity_overrides,
|
||||||
|
)
|
||||||
|
from app.services.event_type_visibility import replace_event_visibility
|
||||||
|
from app.services.ui_settings import (
|
||||||
|
get_effective_ui_settings,
|
||||||
|
upsert_ui_settings,
|
||||||
|
)
|
||||||
|
from app.services.linux_admin_settings import (
|
||||||
|
get_effective_linux_admin_config,
|
||||||
|
upsert_linux_admin_settings,
|
||||||
|
)
|
||||||
|
from app.services.win_admin_settings import (
|
||||||
|
get_effective_win_admin_config,
|
||||||
|
upsert_win_admin_settings,
|
||||||
|
)
|
||||||
|
from app.services.rdp_flap_settings import (
|
||||||
|
get_effective_rdp_flap_settings,
|
||||||
|
upsert_rdp_flap_settings,
|
||||||
|
)
|
||||||
|
from app.services.agent_git_release import get_git_release_versions
|
||||||
|
from app.services.agent_update_settings import (
|
||||||
|
get_effective_agent_update_config,
|
||||||
|
upsert_agent_update_settings,
|
||||||
|
)
|
||||||
|
from app.services.winrm_connect import (
|
||||||
|
HostNotWindowsError,
|
||||||
|
HostTargetMissingError,
|
||||||
|
WinAdminNotConfiguredError,
|
||||||
|
resolve_windows_host_target,
|
||||||
|
test_winrm_connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if len(text) <= visible_tail:
|
||||||
|
return "*" * len(text)
|
||||||
|
return f"{'*' * 8}…{text[-visible_tail:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_url(value: str) -> str | None:
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if len(text) <= 20:
|
||||||
|
return f"{'*' * 6}…{text[-4:]}"
|
||||||
|
return f"{text[:16]}…{text[-6:]}"
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramSettingsResponse(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
configured: bool
|
||||||
|
chat_id_hint: str | None = None
|
||||||
|
bot_token_hint: str | None = None
|
||||||
|
min_severity: str
|
||||||
|
source: str = Field(description="env или db")
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookSettingsResponse(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
configured: bool
|
||||||
|
url_hint: str | None = None
|
||||||
|
secret_header: str | None = None
|
||||||
|
secret_hint: str | None = None
|
||||||
|
min_severity: str
|
||||||
|
source: str = Field(description="env или db")
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsResponse(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
configured: bool
|
||||||
|
smtp_host_hint: str | None = None
|
||||||
|
smtp_port: int
|
||||||
|
smtp_user: str | None = None
|
||||||
|
smtp_password_hint: str | None = None
|
||||||
|
mail_from: str | None = None
|
||||||
|
mail_to_hint: str | None = None
|
||||||
|
smtp_starttls: bool
|
||||||
|
smtp_ssl: bool
|
||||||
|
min_severity: str
|
||||||
|
source: str = Field(description="env или db")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationPolicyResponse(BaseModel):
|
||||||
|
min_severity: str
|
||||||
|
use_telegram: bool
|
||||||
|
use_webhook: bool
|
||||||
|
use_email: bool
|
||||||
|
use_mobile: bool
|
||||||
|
source: str = Field(description="env или db")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSettingsResponse(BaseModel):
|
||||||
|
policy: NotificationPolicyResponse
|
||||||
|
telegram: TelegramSettingsResponse
|
||||||
|
webhook: WebhookSettingsResponse
|
||||||
|
email: EmailSettingsResponse
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationPolicyUpdate(BaseModel):
|
||||||
|
min_severity: str
|
||||||
|
use_telegram: bool
|
||||||
|
use_webhook: bool
|
||||||
|
use_email: bool
|
||||||
|
use_mobile: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramSettingsUpdate(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
bot_token: str | None = None
|
||||||
|
chat_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookSettingsUpdate(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
url: str | None = None
|
||||||
|
secret_header: str | None = None
|
||||||
|
secret: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSettingsUpdate(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
smtp_host: str | None = None
|
||||||
|
smtp_port: int | None = None
|
||||||
|
smtp_user: str | None = None
|
||||||
|
smtp_password: str | None = None
|
||||||
|
mail_from: str | None = None
|
||||||
|
mail_to: str | None = None
|
||||||
|
smtp_starttls: bool | None = None
|
||||||
|
smtp_ssl: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelTestResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_to_response(cfg: NotificationPolicyConfig) -> NotificationPolicyResponse:
|
||||||
|
return NotificationPolicyResponse(
|
||||||
|
min_severity=cfg.min_severity,
|
||||||
|
use_telegram=cfg.use_telegram,
|
||||||
|
use_webhook=cfg.use_webhook,
|
||||||
|
use_email=cfg.use_email,
|
||||||
|
use_mobile=cfg.use_mobile,
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_to_response(cfg: TelegramConfig, policy: NotificationPolicyConfig) -> TelegramSettingsResponse:
|
||||||
|
return TelegramSettingsResponse(
|
||||||
|
enabled=cfg.enabled,
|
||||||
|
configured=cfg.configured,
|
||||||
|
chat_id_hint=_mask_secret(cfg.chat_id),
|
||||||
|
bot_token_hint=_mask_secret(cfg.bot_token),
|
||||||
|
min_severity=policy.min_severity,
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _webhook_to_response(cfg: WebhookConfig, policy: NotificationPolicyConfig) -> WebhookSettingsResponse:
|
||||||
|
return WebhookSettingsResponse(
|
||||||
|
enabled=cfg.enabled,
|
||||||
|
configured=cfg.configured,
|
||||||
|
url_hint=_mask_url(cfg.url),
|
||||||
|
secret_header=cfg.secret_header or None,
|
||||||
|
secret_hint=_mask_secret(cfg.secret),
|
||||||
|
min_severity=policy.min_severity,
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _email_to_response(cfg: EmailConfig, policy: NotificationPolicyConfig) -> EmailSettingsResponse:
|
||||||
|
return EmailSettingsResponse(
|
||||||
|
enabled=cfg.enabled,
|
||||||
|
configured=cfg.configured,
|
||||||
|
smtp_host_hint=_mask_url(cfg.smtp_host) if cfg.smtp_host else None,
|
||||||
|
smtp_port=cfg.smtp_port,
|
||||||
|
smtp_user=cfg.smtp_user or None,
|
||||||
|
smtp_password_hint=_mask_secret(cfg.smtp_password),
|
||||||
|
mail_from=cfg.mail_from or None,
|
||||||
|
mail_to_hint=_mask_secret(cfg.mail_to.replace(";", ",")) if cfg.mail_to else None,
|
||||||
|
smtp_starttls=cfg.smtp_starttls,
|
||||||
|
smtp_ssl=cfg.smtp_ssl,
|
||||||
|
min_severity=policy.min_severity,
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications", response_model=NotificationSettingsResponse)
|
||||||
|
def get_notification_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> NotificationSettingsResponse:
|
||||||
|
policy = get_effective_notification_policy(db)
|
||||||
|
return NotificationSettingsResponse(
|
||||||
|
policy=_policy_to_response(policy),
|
||||||
|
telegram=_telegram_to_response(get_effective_telegram_config(db), policy),
|
||||||
|
webhook=_webhook_to_response(get_effective_webhook_config(db), policy),
|
||||||
|
email=_email_to_response(get_effective_email_config(db), policy),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/notifications/policy", response_model=NotificationPolicyResponse)
|
||||||
|
def update_notification_policy(
|
||||||
|
body: NotificationPolicyUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> NotificationPolicyResponse:
|
||||||
|
if body.min_severity not in VALID_SEVERITIES:
|
||||||
|
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
|
||||||
|
if not any((body.use_telegram, body.use_webhook, body.use_email, body.use_mobile)):
|
||||||
|
raise HTTPException(status_code=422, detail="Выберите хотя бы один канал")
|
||||||
|
try:
|
||||||
|
policy = upsert_notification_policy(
|
||||||
|
db,
|
||||||
|
min_severity=body.min_severity,
|
||||||
|
use_telegram=body.use_telegram,
|
||||||
|
use_webhook=body.use_webhook,
|
||||||
|
use_email=body.use_email,
|
||||||
|
use_mobile=body.use_mobile,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
return _policy_to_response(policy)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/notifications/telegram", response_model=TelegramSettingsResponse)
|
||||||
|
def update_telegram_settings(
|
||||||
|
body: TelegramSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> TelegramSettingsResponse:
|
||||||
|
try:
|
||||||
|
cfg = upsert_telegram_channel(
|
||||||
|
db,
|
||||||
|
enabled=body.enabled,
|
||||||
|
bot_token=body.bot_token,
|
||||||
|
chat_id=body.chat_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
policy = get_effective_notification_policy(db)
|
||||||
|
return _telegram_to_response(cfg, policy)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/notifications/webhook", response_model=WebhookSettingsResponse)
|
||||||
|
def update_webhook_settings(
|
||||||
|
body: WebhookSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> WebhookSettingsResponse:
|
||||||
|
try:
|
||||||
|
cfg = upsert_webhook_channel(
|
||||||
|
db,
|
||||||
|
enabled=body.enabled,
|
||||||
|
url=body.url,
|
||||||
|
secret_header=body.secret_header,
|
||||||
|
secret=body.secret,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
policy = get_effective_notification_policy(db)
|
||||||
|
return _webhook_to_response(cfg, policy)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/notifications/email", response_model=EmailSettingsResponse)
|
||||||
|
def update_email_settings(
|
||||||
|
body: EmailSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> EmailSettingsResponse:
|
||||||
|
try:
|
||||||
|
cfg = upsert_email_channel(
|
||||||
|
db,
|
||||||
|
enabled=body.enabled,
|
||||||
|
smtp_host=body.smtp_host,
|
||||||
|
smtp_port=body.smtp_port,
|
||||||
|
smtp_user=body.smtp_user,
|
||||||
|
smtp_password=body.smtp_password,
|
||||||
|
mail_from=body.mail_from,
|
||||||
|
mail_to=body.mail_to,
|
||||||
|
smtp_starttls=body.smtp_starttls,
|
||||||
|
smtp_ssl=body.smtp_ssl,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
policy = get_effective_notification_policy(db)
|
||||||
|
return _email_to_response(cfg, policy)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notifications/telegram/test", response_model=ChannelTestResponse)
|
||||||
|
def test_telegram_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> ChannelTestResponse:
|
||||||
|
cfg = get_effective_telegram_config(db)
|
||||||
|
try:
|
||||||
|
send_telegram_test_message(config=cfg)
|
||||||
|
except TelegramNotConfiguredError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except TelegramSendError as exc:
|
||||||
|
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
|
||||||
|
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
||||||
|
return ChannelTestResponse(message="Тестовое сообщение отправлено в Telegram")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notifications/webhook/test", response_model=ChannelTestResponse)
|
||||||
|
def test_webhook_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> ChannelTestResponse:
|
||||||
|
cfg = get_effective_webhook_config(db)
|
||||||
|
try:
|
||||||
|
send_webhook_test_message(config=cfg)
|
||||||
|
except WebhookNotConfiguredError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except WebhookSendError as exc:
|
||||||
|
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
|
||||||
|
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
||||||
|
return ChannelTestResponse(message="Тестовый POST отправлен на webhook URL")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notifications/email/test", response_model=ChannelTestResponse)
|
||||||
|
def test_email_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> ChannelTestResponse:
|
||||||
|
cfg = get_effective_email_config(db)
|
||||||
|
try:
|
||||||
|
send_email_test_message(config=cfg)
|
||||||
|
except EmailNotConfiguredError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except EmailSendError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP")
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeverityOverrideRow(BaseModel):
|
||||||
|
event_type: str
|
||||||
|
default_severity: str
|
||||||
|
override_severity: str | None = None
|
||||||
|
show_in_events: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeverityOverridesResponse(BaseModel):
|
||||||
|
items: list[EventSeverityOverrideRow]
|
||||||
|
source: str = Field(description="db — overrides хранятся в PostgreSQL")
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeverityOverridesUpdate(BaseModel):
|
||||||
|
overrides: dict[str, str | None] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="event_type → severity; null или пустая строка сбрасывает override",
|
||||||
|
)
|
||||||
|
visibility: dict[str, bool | None] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="event_type → show_in_events; null или true сбрасывает скрытие (показывать)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _severity_override_rows(items) -> list[EventSeverityOverrideRow]:
|
||||||
|
return [
|
||||||
|
EventSeverityOverrideRow(
|
||||||
|
event_type=i.event_type,
|
||||||
|
default_severity=i.default_severity,
|
||||||
|
override_severity=i.override_severity,
|
||||||
|
show_in_events=i.show_in_events,
|
||||||
|
)
|
||||||
|
for i in items
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
|
||||||
|
def get_severity_overrides(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> EventSeverityOverridesResponse:
|
||||||
|
items = list_severity_override_items(db)
|
||||||
|
return EventSeverityOverridesResponse(
|
||||||
|
items=_severity_override_rows(items),
|
||||||
|
source=SOURCE_DB,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
|
||||||
|
def update_severity_overrides(
|
||||||
|
body: EventSeverityOverridesUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> EventSeverityOverridesResponse:
|
||||||
|
try:
|
||||||
|
items = list_severity_override_items(db)
|
||||||
|
if body.overrides:
|
||||||
|
items = replace_severity_overrides(db, body.overrides)
|
||||||
|
if body.visibility:
|
||||||
|
replace_event_visibility(db, body.visibility)
|
||||||
|
items = list_severity_override_items(db)
|
||||||
|
if not body.overrides and not body.visibility:
|
||||||
|
raise HTTPException(status_code=422, detail="Нет изменений для сохранения")
|
||||||
|
db.commit()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
return EventSeverityOverridesResponse(
|
||||||
|
items=_severity_override_rows(items),
|
||||||
|
source=SOURCE_DB,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UiSettingsResponse(BaseModel):
|
||||||
|
show_sidebar_system_stats: bool
|
||||||
|
source: str = Field(description="db или default")
|
||||||
|
|
||||||
|
|
||||||
|
class UiSettingsUpdate(BaseModel):
|
||||||
|
show_sidebar_system_stats: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ui", response_model=UiSettingsResponse)
|
||||||
|
def get_ui_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> UiSettingsResponse:
|
||||||
|
cfg = get_effective_ui_settings(db)
|
||||||
|
return UiSettingsResponse(
|
||||||
|
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/ui", response_model=UiSettingsResponse)
|
||||||
|
def update_ui_settings(
|
||||||
|
body: UiSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> UiSettingsResponse:
|
||||||
|
cfg = upsert_ui_settings(db, show_sidebar_system_stats=body.show_sidebar_system_stats)
|
||||||
|
return UiSettingsResponse(
|
||||||
|
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WinAdminSettingsResponse(BaseModel):
|
||||||
|
configured: bool
|
||||||
|
user: str | None = None
|
||||||
|
password_hint: str | None = None
|
||||||
|
source: str = Field(description="env или db")
|
||||||
|
|
||||||
|
|
||||||
|
class WinAdminSettingsUpdate(BaseModel):
|
||||||
|
user: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/win-admin", response_model=WinAdminSettingsResponse)
|
||||||
|
def get_win_admin_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> WinAdminSettingsResponse:
|
||||||
|
cfg = get_effective_win_admin_config(db)
|
||||||
|
return WinAdminSettingsResponse(
|
||||||
|
configured=cfg.configured,
|
||||||
|
user=cfg.user or None,
|
||||||
|
password_hint=_mask_secret(cfg.password),
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/win-admin", response_model=WinAdminSettingsResponse)
|
||||||
|
def update_win_admin_settings(
|
||||||
|
body: WinAdminSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> WinAdminSettingsResponse:
|
||||||
|
if body.user is not None and not body.user.strip():
|
||||||
|
raise HTTPException(status_code=422, detail="user must not be empty")
|
||||||
|
cfg = upsert_win_admin_settings(db, user=body.user, password=body.password)
|
||||||
|
return WinAdminSettingsResponse(
|
||||||
|
configured=cfg.configured,
|
||||||
|
user=cfg.user or None,
|
||||||
|
password_hint=_mask_secret(cfg.password),
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RdpFlapSettingsResponse(BaseModel):
|
||||||
|
auto_disconnect: bool
|
||||||
|
win_admin_configured: bool
|
||||||
|
source: str = Field(description="db или default")
|
||||||
|
|
||||||
|
|
||||||
|
class RdpFlapSettingsUpdate(BaseModel):
|
||||||
|
auto_disconnect: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rdp-flap", response_model=RdpFlapSettingsResponse)
|
||||||
|
def get_rdp_flap_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> RdpFlapSettingsResponse:
|
||||||
|
cfg = get_effective_rdp_flap_settings(db)
|
||||||
|
win_cfg = get_effective_win_admin_config(db)
|
||||||
|
return RdpFlapSettingsResponse(
|
||||||
|
auto_disconnect=cfg.auto_disconnect,
|
||||||
|
win_admin_configured=win_cfg.configured,
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/rdp-flap", response_model=RdpFlapSettingsResponse)
|
||||||
|
def update_rdp_flap_settings(
|
||||||
|
body: RdpFlapSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> RdpFlapSettingsResponse:
|
||||||
|
cfg = upsert_rdp_flap_settings(db, auto_disconnect=body.auto_disconnect)
|
||||||
|
win_cfg = get_effective_win_admin_config(db)
|
||||||
|
return RdpFlapSettingsResponse(
|
||||||
|
auto_disconnect=cfg.auto_disconnect,
|
||||||
|
win_admin_configured=win_cfg.configured,
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxAdminSettingsResponse(BaseModel):
|
||||||
|
configured: bool
|
||||||
|
user: str | None = None
|
||||||
|
password_hint: str | None = None
|
||||||
|
source: str = Field(description="env или db")
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxAdminSettingsUpdate(BaseModel):
|
||||||
|
user: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/linux-admin", response_model=LinuxAdminSettingsResponse)
|
||||||
|
def get_linux_admin_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> LinuxAdminSettingsResponse:
|
||||||
|
cfg = get_effective_linux_admin_config(db)
|
||||||
|
return LinuxAdminSettingsResponse(
|
||||||
|
configured=cfg.configured,
|
||||||
|
user=cfg.user or None,
|
||||||
|
password_hint=_mask_secret(cfg.password),
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/linux-admin", response_model=LinuxAdminSettingsResponse)
|
||||||
|
def update_linux_admin_settings(
|
||||||
|
body: LinuxAdminSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> LinuxAdminSettingsResponse:
|
||||||
|
if body.user is not None and not body.user.strip():
|
||||||
|
raise HTTPException(status_code=422, detail="user must not be empty")
|
||||||
|
cfg = upsert_linux_admin_settings(db, user=body.user, password=body.password)
|
||||||
|
return LinuxAdminSettingsResponse(
|
||||||
|
configured=cfg.configured,
|
||||||
|
user=cfg.user or None,
|
||||||
|
password_hint=_mask_secret(cfg.password),
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentUpdateSettingsResponse(BaseModel):
|
||||||
|
mode: str
|
||||||
|
fallback_enabled: bool
|
||||||
|
fallback_after_minutes: int
|
||||||
|
recommended_rdp_version: str | None = None
|
||||||
|
recommended_ssh_version: str | None = None
|
||||||
|
min_rdp_version: str | None = None
|
||||||
|
min_ssh_version: str | None = None
|
||||||
|
win_agent_update_script: str | None = None
|
||||||
|
rdp_git_repo_url: str | None = None
|
||||||
|
ssh_git_repo_url: str | None = None
|
||||||
|
git_branch: str = "main"
|
||||||
|
git_latest_rdp_version: str | None = None
|
||||||
|
git_latest_ssh_version: str | None = None
|
||||||
|
git_fetched_at: datetime | None = None
|
||||||
|
git_errors: dict[str, str] = Field(default_factory=dict)
|
||||||
|
source: str = Field(description="env или db")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentUpdateSettingsUpdate(BaseModel):
|
||||||
|
mode: str | None = None
|
||||||
|
fallback_enabled: bool | None = None
|
||||||
|
fallback_after_minutes: int | None = None
|
||||||
|
recommended_rdp_version: str | None = None
|
||||||
|
recommended_ssh_version: str | None = None
|
||||||
|
min_rdp_version: str | None = None
|
||||||
|
min_ssh_version: str | None = None
|
||||||
|
win_agent_update_script: str | None = None
|
||||||
|
rdp_git_repo_url: str | None = None
|
||||||
|
ssh_git_repo_url: str | None = None
|
||||||
|
git_branch: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentGitTestRequest(BaseModel):
|
||||||
|
rdp_git_repo_url: str | None = None
|
||||||
|
ssh_git_repo_url: str | None = None
|
||||||
|
git_branch: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentGitTestResponse(BaseModel):
|
||||||
|
git_latest_rdp_version: str | None = None
|
||||||
|
git_latest_ssh_version: str | None = None
|
||||||
|
git_fetched_at: datetime | None = None
|
||||||
|
git_errors: dict[str, str] = Field(default_factory=dict)
|
||||||
|
from_cache: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_update_to_response(cfg, git_release=None) -> AgentUpdateSettingsResponse:
|
||||||
|
git_latest_rdp = None
|
||||||
|
git_latest_ssh = None
|
||||||
|
git_fetched_at = None
|
||||||
|
git_errors: dict[str, str] = {}
|
||||||
|
if git_release is not None:
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||||
|
|
||||||
|
git_latest_rdp = git_release.versions.get(PRODUCT_RDP) or None
|
||||||
|
git_latest_ssh = git_release.versions.get(PRODUCT_SSH) or None
|
||||||
|
git_fetched_at = git_release.fetched_at
|
||||||
|
git_errors = dict(git_release.errors)
|
||||||
|
return AgentUpdateSettingsResponse(
|
||||||
|
mode=cfg.mode,
|
||||||
|
fallback_enabled=cfg.fallback_enabled,
|
||||||
|
fallback_after_minutes=cfg.fallback_after_minutes,
|
||||||
|
recommended_rdp_version=cfg.recommended_rdp_version or None,
|
||||||
|
recommended_ssh_version=cfg.recommended_ssh_version or None,
|
||||||
|
min_rdp_version=cfg.min_rdp_version or None,
|
||||||
|
min_ssh_version=cfg.min_ssh_version or None,
|
||||||
|
win_agent_update_script=cfg.win_agent_update_script or None,
|
||||||
|
rdp_git_repo_url=cfg.rdp_git_repo_url or None,
|
||||||
|
ssh_git_repo_url=cfg.ssh_git_repo_url or None,
|
||||||
|
git_branch=cfg.git_branch or "main",
|
||||||
|
git_latest_rdp_version=git_latest_rdp,
|
||||||
|
git_latest_ssh_version=git_latest_ssh,
|
||||||
|
git_fetched_at=git_fetched_at,
|
||||||
|
git_errors=git_errors,
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agent-updates", response_model=AgentUpdateSettingsResponse)
|
||||||
|
def get_agent_update_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> AgentUpdateSettingsResponse:
|
||||||
|
cfg = get_effective_agent_update_config(db)
|
||||||
|
git_release = get_git_release_versions(cfg, db=db)
|
||||||
|
return _agent_update_to_response(cfg, git_release)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/agent-updates", response_model=AgentUpdateSettingsResponse)
|
||||||
|
def update_agent_update_settings(
|
||||||
|
body: AgentUpdateSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> AgentUpdateSettingsResponse:
|
||||||
|
try:
|
||||||
|
cfg = upsert_agent_update_settings(
|
||||||
|
db,
|
||||||
|
mode=body.mode,
|
||||||
|
fallback_enabled=body.fallback_enabled,
|
||||||
|
fallback_after_minutes=body.fallback_after_minutes,
|
||||||
|
recommended_rdp_version=body.recommended_rdp_version,
|
||||||
|
recommended_ssh_version=body.recommended_ssh_version,
|
||||||
|
min_rdp_version=body.min_rdp_version,
|
||||||
|
min_ssh_version=body.min_ssh_version,
|
||||||
|
win_agent_update_script=body.win_agent_update_script,
|
||||||
|
rdp_git_repo_url=body.rdp_git_repo_url,
|
||||||
|
ssh_git_repo_url=body.ssh_git_repo_url,
|
||||||
|
git_branch=body.git_branch,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
|
||||||
|
return _agent_update_to_response(cfg, git_release)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agent-updates/test-git", response_model=AgentGitTestResponse)
|
||||||
|
def test_agent_git_release(
|
||||||
|
body: AgentGitTestRequest | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> AgentGitTestResponse:
|
||||||
|
cfg = get_effective_agent_update_config(db)
|
||||||
|
if body is not None:
|
||||||
|
cfg = replace(
|
||||||
|
cfg,
|
||||||
|
rdp_git_repo_url=(body.rdp_git_repo_url or cfg.rdp_git_repo_url).strip(),
|
||||||
|
ssh_git_repo_url=(body.ssh_git_repo_url or cfg.ssh_git_repo_url).strip(),
|
||||||
|
git_branch=(body.git_branch or cfg.git_branch or "main").strip() or "main",
|
||||||
|
)
|
||||||
|
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||||
|
|
||||||
|
return AgentGitTestResponse(
|
||||||
|
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
|
||||||
|
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
|
||||||
|
git_fetched_at=git_release.fetched_at,
|
||||||
|
git_errors=dict(git_release.errors),
|
||||||
|
from_cache=git_release.from_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginSecuritySettingsResponse(BaseModel):
|
||||||
|
ip_whitelist: list[str] = Field(default_factory=list)
|
||||||
|
ip_whitelist_text: str = ""
|
||||||
|
sync_fail2ban: bool = False
|
||||||
|
max_failures: int = 3
|
||||||
|
window_minutes: int = 15
|
||||||
|
fail2ban_available: bool = False
|
||||||
|
source: str = "default"
|
||||||
|
|
||||||
|
|
||||||
|
class LoginSecuritySettingsUpdate(BaseModel):
|
||||||
|
ip_whitelist_text: str = ""
|
||||||
|
sync_fail2ban: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoginBlockItem(BaseModel):
|
||||||
|
ip_address: str
|
||||||
|
scope: str
|
||||||
|
failure_count: int | None = None
|
||||||
|
usernames: list[str] = Field(default_factory=list)
|
||||||
|
blocked_until: datetime | None = None
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginUnblockRequest(BaseModel):
|
||||||
|
ip_address: str
|
||||||
|
scope: str = Field(default="both", description="web | ssh | both")
|
||||||
|
|
||||||
|
|
||||||
|
class LoginUnblockResponse(BaseModel):
|
||||||
|
messages: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _login_security_response(db: Session) -> LoginSecuritySettingsResponse:
|
||||||
|
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||||
|
from app.services.fail2ban_sync import fail2ban_available
|
||||||
|
from app.services.login_security_settings import get_effective_login_security_config
|
||||||
|
|
||||||
|
cfg = get_effective_login_security_config(db)
|
||||||
|
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
|
text = (row.login_ip_whitelist or "") if row is not None else ""
|
||||||
|
return LoginSecuritySettingsResponse(
|
||||||
|
ip_whitelist=list(cfg.ip_whitelist),
|
||||||
|
ip_whitelist_text=text,
|
||||||
|
sync_fail2ban=cfg.sync_fail2ban,
|
||||||
|
max_failures=cfg.max_failures,
|
||||||
|
window_minutes=cfg.window_minutes,
|
||||||
|
fail2ban_available=fail2ban_available(),
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _block_item(entry) -> LoginBlockItem:
|
||||||
|
return LoginBlockItem(
|
||||||
|
ip_address=entry.ip_address,
|
||||||
|
scope=entry.scope,
|
||||||
|
failure_count=entry.failure_count,
|
||||||
|
usernames=list(entry.usernames),
|
||||||
|
blocked_until=entry.blocked_until,
|
||||||
|
reason=entry.reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login-security", response_model=LoginSecuritySettingsResponse)
|
||||||
|
def get_login_security_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> LoginSecuritySettingsResponse:
|
||||||
|
return _login_security_response(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/login-security", response_model=LoginSecuritySettingsResponse)
|
||||||
|
def update_login_security_settings(
|
||||||
|
body: LoginSecuritySettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> LoginSecuritySettingsResponse:
|
||||||
|
from app.services.login_security_settings import upsert_login_security_settings
|
||||||
|
|
||||||
|
upsert_login_security_settings(
|
||||||
|
db,
|
||||||
|
ip_whitelist_text=body.ip_whitelist_text,
|
||||||
|
sync_fail2ban=bool(body.sync_fail2ban),
|
||||||
|
)
|
||||||
|
return _login_security_response(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login-security/blocks", response_model=list[LoginBlockItem])
|
||||||
|
def list_login_security_blocks(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> list[LoginBlockItem]:
|
||||||
|
from app.services.login_security_settings import list_all_login_blocks
|
||||||
|
|
||||||
|
return [_block_item(e) for e in list_all_login_blocks(db)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login-security/unblock", response_model=LoginUnblockResponse)
|
||||||
|
def unblock_login_security_ip(
|
||||||
|
body: LoginUnblockRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> LoginUnblockResponse:
|
||||||
|
from app.services.login_security_settings import unblock_login_ip
|
||||||
|
|
||||||
|
scope = (body.scope or "both").strip().lower()
|
||||||
|
if scope not in ("web", "ssh", "both"):
|
||||||
|
raise HTTPException(status_code=422, detail="scope must be web, ssh, or both")
|
||||||
|
messages = unblock_login_ip(db, body.ip_address, scope=scope)
|
||||||
|
return LoginUnblockResponse(messages=messages)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Cookie, Depends, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import verify_access_token
|
||||||
|
from app.auth.stream_cookie import STREAM_COOKIE_NAME
|
||||||
|
from app.database import SessionLocal, get_db
|
||||||
|
from app.models import Event, Problem
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
|
||||||
|
from app.version import APP_VERSION
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/stream", tags=["stream"])
|
||||||
|
|
||||||
|
SSE_INTERVAL_SEC = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_snapshot(db: Session) -> dict:
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
events_24h = db.scalar(
|
||||||
|
select(func.count()).select_from(Event).where(Event.received_at >= since)
|
||||||
|
) or 0
|
||||||
|
problems_open = (
|
||||||
|
db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0
|
||||||
|
)
|
||||||
|
last_event = db.scalar(select(Event.id).order_by(Event.received_at.desc()).limit(1))
|
||||||
|
settings = get_settings()
|
||||||
|
return {
|
||||||
|
"type": "dashboard",
|
||||||
|
"version": APP_VERSION,
|
||||||
|
"events_last_24h": events_24h,
|
||||||
|
"hosts_stale": count_stale_hosts(db, settings.sac_heartbeat_stale_minutes),
|
||||||
|
"heartbeats_24h": db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Event)
|
||||||
|
.where(Event.received_at >= since, Event.type == HEARTBEAT_TYPE)
|
||||||
|
)
|
||||||
|
or 0,
|
||||||
|
"daily_reports_24h": db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Event)
|
||||||
|
.where(Event.received_at >= since, Event.type.in_(DAILY_REPORT_TYPES))
|
||||||
|
)
|
||||||
|
or 0,
|
||||||
|
"problems_open": problems_open,
|
||||||
|
"last_event_id": last_event,
|
||||||
|
"at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _sse_generator():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
payload = _dashboard_snapshot(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||||
|
await asyncio.sleep(SSE_INTERVAL_SEC)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_auth(
|
||||||
|
sac_stream: str | None = Cookie(None, alias=STREAM_COOKIE_NAME),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> str:
|
||||||
|
token = (sac_stream or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=401, detail="SSE authentication required")
|
||||||
|
return verify_access_token(token, db=db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
async def stream_events(
|
||||||
|
_user: str = Depends(_sse_auth),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
return StreamingResponse(
|
||||||
|
_sse_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.system_stats import collect_system_stats
|
||||||
|
from app.services.ui_settings import get_effective_ui_settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/system", tags=["system"])
|
||||||
|
|
||||||
|
|
||||||
|
class SystemStatsResponse(BaseModel):
|
||||||
|
visible: bool = Field(description="Показывать блок в боковой панели (глобальная настройка)")
|
||||||
|
collected_at: str
|
||||||
|
cpu_percent: float | None = None
|
||||||
|
memory: dict | None = None
|
||||||
|
disk: dict | None = None
|
||||||
|
database: dict
|
||||||
|
app: dict
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=SystemStatsResponse)
|
||||||
|
def get_system_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(get_current_user),
|
||||||
|
) -> SystemStatsResponse:
|
||||||
|
ui = get_effective_ui_settings(db)
|
||||||
|
payload = collect_system_stats(db)
|
||||||
|
return SystemStatsResponse(visible=ui.show_sidebar_system_stats, **payload)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import CurrentUser, require_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, USER_ROLES, User
|
||||||
|
from app.services.user_auth import create_user, hash_password
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
class UserSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponse(BaseModel):
|
||||||
|
items: list[UserSummary]
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=2, max_length=64)
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
role: str = Field(default=USER_ROLE_MONITOR)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserRequest(BaseModel):
|
||||||
|
username: str | None = Field(default=None, min_length=2, max_length=64)
|
||||||
|
role: str | None = None
|
||||||
|
password: str | None = Field(default=None, min_length=8, max_length=128)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=UserListResponse)
|
||||||
|
def list_users(
|
||||||
|
_admin: CurrentUser = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> UserListResponse:
|
||||||
|
rows = db.scalars(select(User).order_by(User.username)).all()
|
||||||
|
return UserListResponse(
|
||||||
|
items=[
|
||||||
|
UserSummary(
|
||||||
|
id=u.id,
|
||||||
|
username=u.username,
|
||||||
|
role=u.role,
|
||||||
|
is_active=u.is_active,
|
||||||
|
created_at=u.created_at,
|
||||||
|
)
|
||||||
|
for u in rows
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UserSummary, status_code=201)
|
||||||
|
def create_user_endpoint(
|
||||||
|
body: CreateUserRequest,
|
||||||
|
admin: CurrentUser = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> UserSummary:
|
||||||
|
if body.role not in USER_ROLES:
|
||||||
|
raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}")
|
||||||
|
try:
|
||||||
|
user = create_user(db, username=body.username, password=body.password, role=body.role)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return UserSummary(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}", response_model=UserSummary)
|
||||||
|
def update_user(
|
||||||
|
user_id: int,
|
||||||
|
body: UpdateUserRequest,
|
||||||
|
admin: CurrentUser = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> UserSummary:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if body.username is not None:
|
||||||
|
normalized = body.username.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise HTTPException(status_code=422, detail="username is required")
|
||||||
|
existing = db.scalar(
|
||||||
|
select(User).where(
|
||||||
|
func.lower(User.username) == normalized.lower(),
|
||||||
|
User.id != user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="username already exists")
|
||||||
|
user.username = normalized
|
||||||
|
|
||||||
|
if body.role is not None:
|
||||||
|
if body.role not in USER_ROLES:
|
||||||
|
raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}")
|
||||||
|
if user.role == USER_ROLE_ADMIN and body.role != USER_ROLE_ADMIN:
|
||||||
|
_ensure_not_last_admin(db, user.id)
|
||||||
|
user.role = body.role
|
||||||
|
|
||||||
|
if body.password is not None:
|
||||||
|
user.password_hash = hash_password(body.password)
|
||||||
|
|
||||||
|
if body.is_active is not None:
|
||||||
|
if not body.is_active and user.username.lower() == admin.username.lower():
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
|
||||||
|
if not body.is_active and user.role == USER_ROLE_ADMIN:
|
||||||
|
_ensure_not_last_admin(db, user.id)
|
||||||
|
user.is_active = body.is_active
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return UserSummary(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_not_last_admin(db: Session, user_id: int) -> None:
|
||||||
|
active_admins = db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(User)
|
||||||
|
.where(User.role == USER_ROLE_ADMIN, User.is_active.is_(True), User.id != user_id)
|
||||||
|
)
|
||||||
|
if not active_admins:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot remove or deactivate the last admin user")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Security
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import ApiKey
|
||||||
|
|
||||||
|
security_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_hash_api_key(raw_key: str) -> str:
|
||||||
|
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_api_key(raw_key: str) -> str:
|
||||||
|
secret = get_settings().jwt_secret.encode("utf-8")
|
||||||
|
return hmac.new(secret, raw_key.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_api_key_hash(raw_key: str, stored_hash: str) -> bool:
|
||||||
|
if hmac.compare_digest(hash_api_key(raw_key), stored_hash):
|
||||||
|
return True
|
||||||
|
return hmac.compare_digest(_legacy_hash_api_key(raw_key), stored_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key() -> tuple[str, str, str]:
|
||||||
|
"""Returns (full_key, prefix, hash)."""
|
||||||
|
raw = f"sac_{secrets.token_urlsafe(32)}"
|
||||||
|
prefix = raw[:12]
|
||||||
|
return raw, prefix, hash_api_key(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_api_key(db: Session, raw_key: str) -> bool:
|
||||||
|
rows = db.scalars(select(ApiKey).where(ApiKey.is_active.is_(True))).all()
|
||||||
|
for row in rows:
|
||||||
|
if verify_api_key_hash(raw_key, row.key_hash):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_key_auth(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Security(security_scheme),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> str:
|
||||||
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||||
|
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
||||||
|
if not verify_api_key(db, credentials.credentials):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||||
|
return credentials.credentials
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from app.models.user import USER_ROLE_ADMIN, User
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUser:
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
device_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return self.role == USER_ROLE_ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(subject: str, role: str, *, device_id: int | None = None) -> str:
|
||||||
|
settings = get_settings()
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||||
|
payload: dict[str, object] = {"sub": subject, "role": role, "exp": expire}
|
||||||
|
if device_id is not None:
|
||||||
|
payload["device_id"] = device_id
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_mobile_device_active(db: Session, device_id: int, user_id: int) -> None:
|
||||||
|
from app.models.mobile_device import MobileDevice
|
||||||
|
|
||||||
|
device = db.get(MobileDevice, device_id)
|
||||||
|
if device is None or device.user_id != user_id or device.revoked_at is not None:
|
||||||
|
raise HTTPException(status_code=401, detail="Mobile device revoked or invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_device_id(token: str) -> int | None:
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.jwt_secret,
|
||||||
|
algorithms=[settings.jwt_algorithm],
|
||||||
|
)
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
raw = payload.get("device_id")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _user_from_db(db: Session, username: str) -> User:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
normalized = username.strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
user = db.scalar(select(User).where(func.lower(User.username) == normalized.lower()))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_current_user(token: str, db: Session | None = None) -> CurrentUser:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
payload = jwt.decode(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
token,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
settings.jwt_secret,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
algorithms=[settings.jwt_algorithm],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
username = payload.get("sub")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
parsed_device_id: int | None = None
|
||||||
|
raw_device_id = payload.get("device_id")
|
||||||
|
if raw_device_id is not None:
|
||||||
|
try:
|
||||||
|
parsed_device_id = int(raw_device_id)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token") from exc
|
||||||
|
|
||||||
|
if db is not None:
|
||||||
|
user = _user_from_db(db, str(username))
|
||||||
|
if parsed_device_id is not None:
|
||||||
|
_assert_mobile_device_active(db, parsed_device_id, user.id)
|
||||||
|
return CurrentUser(username=user.username, role=user.role, device_id=parsed_device_id)
|
||||||
|
|
||||||
|
role = str(payload.get("role") or USER_ROLE_ADMIN)
|
||||||
|
return CurrentUser(username=str(username), role=role, device_id=parsed_device_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
except JWTError as exc:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token") from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def verify_access_token(token: str, db: Session | None = None) -> str:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"""Проверка JWT (в т.ч. query-параметр для SSE). Возвращает username."""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return _decode_current_user(token, db=db).username
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
) -> CurrentUser:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
detail="Not authenticated",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return _decode_current_user(credentials.credentials, db=db)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if not user.is_admin:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
detail="Admin access required",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""httpOnly cookie auth for SSE (EventSource cannot send Authorization header)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Response
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
STREAM_COOKIE_NAME = "sac_stream"
|
||||||
|
STREAM_COOKIE_PATH = "/api/v1/stream"
|
||||||
|
|
||||||
|
|
||||||
|
def stream_cookie_kwargs(settings: Settings) -> dict[str, object]:
|
||||||
|
secure = settings.sac_public_url.lower().startswith("https://")
|
||||||
|
return {
|
||||||
|
"key": STREAM_COOKIE_NAME,
|
||||||
|
"httponly": True,
|
||||||
|
"secure": secure,
|
||||||
|
"samesite": "strict",
|
||||||
|
"path": STREAM_COOKIE_PATH,
|
||||||
|
"max_age": max(60, int(settings.jwt_expire_minutes) * 60),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_stream_auth_cookie(response: Response, token: str, settings: Settings) -> None:
|
||||||
|
response.set_cookie(value=token, **stream_cookie_kwargs(settings))
|
||||||
|
|
||||||
|
|
||||||
|
def clear_stream_auth_cookie(response: Response, settings: Settings) -> None:
|
||||||
|
kwargs = stream_cookie_kwargs(settings)
|
||||||
|
response.delete_cookie(
|
||||||
|
key=kwargs["key"],
|
||||||
|
path=kwargs["path"],
|
||||||
|
secure=bool(kwargs["secure"]),
|
||||||
|
httponly=True,
|
||||||
|
samesite="strict",
|
||||||
|
)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SCHEMA_PATH = ROOT / "schemas" / "event-schema-v1.json"
|
||||||
|
DEFAULT_CONFIG_FILE = Path("/opt/security-alert-center/config/sac-api.env")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_config_files() -> tuple[str, ...]:
|
||||||
|
"""Файлы конфигурации для pydantic (корректный разбор кавычек и URL)."""
|
||||||
|
candidates: list[Path] = []
|
||||||
|
explicit = os.environ.get("SAC_CONFIG_FILE", "").strip()
|
||||||
|
if explicit:
|
||||||
|
candidates.append(Path(explicit))
|
||||||
|
else:
|
||||||
|
candidates.append(DEFAULT_CONFIG_FILE)
|
||||||
|
candidates.append(Path(".env"))
|
||||||
|
return tuple(str(p) for p in candidates if p.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_config() -> SettingsConfigDict:
|
||||||
|
files = _resolve_config_files()
|
||||||
|
return SettingsConfigDict(
|
||||||
|
env_file=files if files else None,
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = _settings_config()
|
||||||
|
|
||||||
|
database_url: str = "postgresql+psycopg2://sac:sac@localhost:5432/sac"
|
||||||
|
sac_db_pool_size: int = 15
|
||||||
|
sac_db_max_overflow: int = 25
|
||||||
|
sac_uvicorn_workers: int = 4
|
||||||
|
sac_public_url: str = "http://localhost:8000"
|
||||||
|
# URL для скачивания RDP bundle с ПК (WinRM). По умолчанию = SAC_PUBLIC_URL.
|
||||||
|
sac_agent_bundle_base_url: str = ""
|
||||||
|
jwt_secret: str = "change-me-in-production"
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
jwt_expire_minutes: int = 60 * 24
|
||||||
|
# Fail-fast on weak JWT/CORS defaults (disable only for local dev/tests).
|
||||||
|
sac_security_enforce: bool = True
|
||||||
|
sac_ingest_max_body_bytes: int = 2_097_152
|
||||||
|
|
||||||
|
sac_bootstrap_api_key: str = ""
|
||||||
|
sac_admin_username: str = "admin"
|
||||||
|
sac_admin_password: str = ""
|
||||||
|
|
||||||
|
event_schema_path: str = str(SCHEMA_PATH)
|
||||||
|
cors_origins: str = "*"
|
||||||
|
telegram_enabled: bool = False
|
||||||
|
telegram_bot_token: str = ""
|
||||||
|
telegram_chat_id: str = ""
|
||||||
|
telegram_min_severity: str = "high"
|
||||||
|
|
||||||
|
webhook_enabled: bool = False
|
||||||
|
webhook_url: str = ""
|
||||||
|
webhook_secret_header: str = ""
|
||||||
|
webhook_secret: str = ""
|
||||||
|
webhook_min_severity: str = "high"
|
||||||
|
|
||||||
|
smtp_enabled: bool = False
|
||||||
|
smtp_host: str = ""
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_user: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_from: str = ""
|
||||||
|
smtp_to: str = ""
|
||||||
|
smtp_starttls: bool = True
|
||||||
|
smtp_ssl: bool = False
|
||||||
|
smtp_min_severity: str = "high"
|
||||||
|
|
||||||
|
notify_min_severity: str = "warning"
|
||||||
|
notify_channels: str = "telegram,webhook,email"
|
||||||
|
|
||||||
|
# F-NOT-03: cooldown исходящих оповещений (dedup_key / fingerprint)
|
||||||
|
sac_notify_cooldown_enabled: bool = True
|
||||||
|
sac_notify_event_cooldown_sec: int = 90
|
||||||
|
sac_notify_problem_cooldown_sec: int = 300
|
||||||
|
|
||||||
|
# F-NOT-05: суточные отчёты из агрегации событий SAC (для exclusive / без отчёта агента)
|
||||||
|
sac_daily_report_enabled: bool = True
|
||||||
|
sac_daily_report_hour: int = 9
|
||||||
|
sac_daily_report_timezone: str = "Europe/Moscow"
|
||||||
|
sac_daily_report_skip_if_agent_sent: bool = True
|
||||||
|
sac_daily_report_require_activity: bool = True
|
||||||
|
|
||||||
|
# Порог «живости» агента
|
||||||
|
sac_heartbeat_stale_minutes: int = 300
|
||||||
|
|
||||||
|
# Периодическое сканирование stale heartbeat (proactive host_silence)
|
||||||
|
sac_host_silence_scan_enabled: bool = True
|
||||||
|
sac_host_silence_scan_interval_minutes: int = 5
|
||||||
|
# После ручного закрытия host_silence не открывать снова раньше N часов (auto-close по heartbeat — без паузы).
|
||||||
|
sac_host_silence_manual_resolve_cooldown_hours: int = 12
|
||||||
|
|
||||||
|
# Окно корреляции Problems: host + type + rule в одном open Problem
|
||||||
|
sac_problem_correlation_window_minutes: int = 60
|
||||||
|
|
||||||
|
# rule:brute_force_burst — ssh.login.failed / rdp.login.failed
|
||||||
|
sac_brute_force_window_minutes: int = 15
|
||||||
|
sac_brute_force_threshold: int = 30
|
||||||
|
|
||||||
|
# rule:privilege_spike — privilege.sudo.command
|
||||||
|
sac_privilege_spike_window_minutes: int = 10
|
||||||
|
sac_privilege_spike_threshold: int = 10
|
||||||
|
|
||||||
|
# rule:rdg_session_flap — RD Gateway 302→303 within window
|
||||||
|
sac_rdg_flap_window_min_sec: int = 1
|
||||||
|
sac_rdg_flap_window_max_sec: int = 10
|
||||||
|
sac_rdg_flap_dedup_sec: int = 30
|
||||||
|
# Внешние IP HAProxy/RDG-прокси: если external_ip в списке — путь Haproxy-RDG-Comp, иначе RDG-Comp
|
||||||
|
sac_rdg_haproxy_external_ips: str = ""
|
||||||
|
|
||||||
|
# Windows admin for agent qwinsta/logoff (domain-wide)
|
||||||
|
sac_win_admin_user: str = ""
|
||||||
|
sac_win_admin_password: str = ""
|
||||||
|
sac_winrm_use_https: bool = False
|
||||||
|
sac_winrm_server_cert_validation: str = "validate"
|
||||||
|
|
||||||
|
# Linux SSH admin for remote agent update (fallback SSH, phase 5)
|
||||||
|
sac_linux_admin_user: str = ""
|
||||||
|
sac_linux_admin_password: str = ""
|
||||||
|
sac_ssh_known_hosts_file: str = "/opt/security-alert-center/config/ssh_known_hosts"
|
||||||
|
sac_ssh_auto_add_host_key: bool = False
|
||||||
|
|
||||||
|
# Agent updates (mode gpo|sac, recommended versions, WinRM fallback script)
|
||||||
|
sac_agent_update_mode: str = "gpo"
|
||||||
|
sac_agent_update_fallback_enabled: bool = True
|
||||||
|
sac_agent_update_fallback_minutes: int = 15
|
||||||
|
sac_agent_recommended_rdp_version: str = ""
|
||||||
|
sac_agent_recommended_ssh_version: str = ""
|
||||||
|
sac_agent_min_rdp_version: str = ""
|
||||||
|
sac_agent_min_ssh_version: str = ""
|
||||||
|
sac_win_agent_update_script: str = ""
|
||||||
|
sac_agent_rdp_git_repo_url: str = "https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
|
||||||
|
sac_agent_ssh_git_repo_url: str = "https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
|
||||||
|
sac_agent_git_branch: str = "main"
|
||||||
|
sac_agent_git_cache_dir: str = "/opt/security-alert-center/cache/agent-repos"
|
||||||
|
sac_agent_git_cache_ttl_minutes: int = 30
|
||||||
|
sac_agent_git_token: str = ""
|
||||||
|
|
||||||
|
# Retention (app.jobs.retention / systemd timer)
|
||||||
|
sac_events_retention_days: int = 90
|
||||||
|
sac_problems_retention_days: int = 180
|
||||||
|
|
||||||
|
# UI login brute-force protection
|
||||||
|
sac_login_max_failures: int = 3
|
||||||
|
sac_login_failure_window_minutes: int = 15
|
||||||
|
sac_login_alert_telegram: bool = True
|
||||||
|
sac_login_ip_whitelist: str = ""
|
||||||
|
sac_fail2ban_ssh_jail: str = "sshd"
|
||||||
|
sac_fail2ban_sync_enabled: bool = False
|
||||||
|
sac_fail2ban_ignoreip_file: str = "/etc/fail2ban/jail.d/sac-login-whitelist.local"
|
||||||
|
|
||||||
|
# Seaca mobile (FCM push)
|
||||||
|
sac_fcm_enabled: bool = False
|
||||||
|
sac_fcm_project_id: str = ""
|
||||||
|
sac_fcm_service_account_json: str = ""
|
||||||
|
sac_mobile_refresh_expire_days: int = 90
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Known SAC event types and default severities from agents (schema v1 / agent-integration)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
|
||||||
|
# Agent / lifecycle
|
||||||
|
"agent.heartbeat": "info",
|
||||||
|
"agent.lifecycle": "info",
|
||||||
|
"agent.inventory": "info",
|
||||||
|
"agent.test": "info",
|
||||||
|
"agent.recovered": "info",
|
||||||
|
# SSH
|
||||||
|
"ssh.login.success": "info",
|
||||||
|
"ssh.login.failed": "warning",
|
||||||
|
"ssh.ip.banned": "high",
|
||||||
|
"ssh.ip.bruteforce.threshold": "warning",
|
||||||
|
"ssh.bruteforce.mass": "high",
|
||||||
|
"privilege.sudo.command": "warning",
|
||||||
|
"session.logind.new": "info",
|
||||||
|
"session.logind.removed": "info",
|
||||||
|
"session.logind.failed": "warning",
|
||||||
|
"report.daily.ssh": "info",
|
||||||
|
# RDP / Windows
|
||||||
|
"rdp.login.success": "info",
|
||||||
|
"rdp.login.failed": "warning",
|
||||||
|
"rdp.session.logoff": "info",
|
||||||
|
"rdp.shadow.control.started": "warning",
|
||||||
|
"rdp.shadow.control.stopped": "info",
|
||||||
|
"rdp.shadow.control.permission": "warning",
|
||||||
|
"winrm.session.started": "warning",
|
||||||
|
"smb.admin_share.access": "warning",
|
||||||
|
"auth.explicit.credentials": "warning",
|
||||||
|
"report.daily.rdp": "info",
|
||||||
|
"rdp.ip.banned": "high",
|
||||||
|
"rdp.ip.ban": "high",
|
||||||
|
# RD Gateway
|
||||||
|
"rdg.connection.success": "info",
|
||||||
|
"rdg.connection.disconnected": "info",
|
||||||
|
"rdg.connection.failed": "warning",
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
_engine_kwargs: dict = {"pool_pre_ping": True}
|
||||||
|
if settings.database_url.startswith("postgresql"):
|
||||||
|
_engine_kwargs["pool_size"] = max(1, int(settings.sac_db_pool_size))
|
||||||
|
_engine_kwargs["max_overflow"] = max(0, int(settings.sac_db_max_overflow))
|
||||||
|
engine = create_engine(settings.database_url, **_engine_kwargs)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Фоновые задачи SAC (retention и др.)."""
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""CLI: python -m app.jobs.daily_report [--force]"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services.daily_report import run_daily_reports
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger("sac.daily_report")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Generate SAC daily reports from DB events")
|
||||||
|
parser.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="Ignore SAC_DAILY_REPORT_HOUR (for manual run / tests)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
results = run_daily_reports(db, force=args.force)
|
||||||
|
created = [r for r in results if r.created]
|
||||||
|
logger.info("created %s reports", len(created))
|
||||||
|
for r in created:
|
||||||
|
logger.info(" %s %s event_id=%s", r.hostname, r.report_type, r.event_id)
|
||||||
|
skipped = [r for r in results if not r.created and r.skipped_reason]
|
||||||
|
for r in skipped:
|
||||||
|
logger.info(" skip %s: %s", r.hostname, r.skipped_reason)
|
||||||
|
return 0
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("daily report job failed")
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Фоновый asyncio-цикл сканирования stale heartbeat (в процессе sac-api)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services.host_silence_scan import dispatch_host_silence_notifications, run_host_silence_scan
|
||||||
|
|
||||||
|
logger = logging.getLogger("sac.host_silence_scan")
|
||||||
|
|
||||||
|
|
||||||
|
async def host_silence_scan_loop(stop_event: asyncio.Event) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
interval_sec = max(60, int(settings.sac_host_silence_scan_interval_minutes) * 60)
|
||||||
|
logger.info(
|
||||||
|
"host_silence background scan enabled (interval=%s min, stale=%s min)",
|
||||||
|
settings.sac_host_silence_scan_interval_minutes,
|
||||||
|
settings.sac_heartbeat_stale_minutes,
|
||||||
|
)
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_run_scan_once)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("host_silence background scan error")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(stop_event.wait(), timeout=interval_sec)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scan_once() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
results = run_host_silence_scan(db)
|
||||||
|
notified = dispatch_host_silence_notifications(db, results)
|
||||||
|
from app.services.agent_update import process_agent_update_fallbacks
|
||||||
|
|
||||||
|
fallback_results = process_agent_update_fallbacks(db)
|
||||||
|
db.commit()
|
||||||
|
if results:
|
||||||
|
logger.info(
|
||||||
|
"host_silence background: stale=%s created=%s notified=%s",
|
||||||
|
len(results),
|
||||||
|
sum(1 for r in results if r.created),
|
||||||
|
notified,
|
||||||
|
)
|
||||||
|
if fallback_results:
|
||||||
|
logger.info(
|
||||||
|
"agent_update fallback: processed=%s ok=%s",
|
||||||
|
len(fallback_results),
|
||||||
|
sum(1 for r in fallback_results if r.get("ok")),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""CLI: python -m app.jobs.host_silence_scan"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services.host_silence_scan import dispatch_host_silence_notifications, run_host_silence_scan
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger("sac.host_silence_scan")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
results = run_host_silence_scan(db)
|
||||||
|
notified = dispatch_host_silence_notifications(db, results)
|
||||||
|
db.commit()
|
||||||
|
created = sum(1 for r in results if r.created)
|
||||||
|
logger.info(
|
||||||
|
"host_silence scan done stale_hosts=%s created=%s notified=%s",
|
||||||
|
len(results),
|
||||||
|
created,
|
||||||
|
notified,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("host_silence scan failed")
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""CLI: python -m app.jobs.retention"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services.retention import purge_old_data
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger("sac.retention")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
settings = get_settings()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stats = purge_old_data(db, settings)
|
||||||
|
logger.info(
|
||||||
|
"retention done events_deleted=%s problems_deleted=%s",
|
||||||
|
stats["events_deleted"],
|
||||||
|
stats["problems_deleted"],
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("retention failed")
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager, suppress
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.v1.router import api_router
|
||||||
|
from app.auth.api_key import hash_api_key
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware
|
||||||
|
from app.models import ApiKey
|
||||||
|
from app.security_bootstrap import validate_security_settings, warn_relaxed_security
|
||||||
|
from app.services.user_auth import bootstrap_admin_user
|
||||||
|
from app.version import APP_VERSION, APP_VERSION_LABEL
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
logger = logging.getLogger("sac")
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_api_key() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.sac_bootstrap_api_key:
|
||||||
|
return
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
key_hash = hash_api_key(settings.sac_bootstrap_api_key)
|
||||||
|
exists = db.scalar(select(ApiKey).where(ApiKey.key_hash == key_hash))
|
||||||
|
if exists:
|
||||||
|
return
|
||||||
|
prefix = settings.sac_bootstrap_api_key[:12]
|
||||||
|
db.add(
|
||||||
|
ApiKey(
|
||||||
|
name="bootstrap",
|
||||||
|
key_prefix=prefix,
|
||||||
|
key_hash=key_hash,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_users() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
bootstrap_admin_user(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_stale_remote_actions() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from app.services.host_remote_actions import clear_stale_running_remote_actions
|
||||||
|
|
||||||
|
cleared = clear_stale_running_remote_actions(db)
|
||||||
|
for hostname in cleared:
|
||||||
|
logger.info("Stale remote action reset on startup: %s", hostname)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
settings = get_settings()
|
||||||
|
validate_security_settings(settings)
|
||||||
|
warn_relaxed_security(settings)
|
||||||
|
logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION)
|
||||||
|
bootstrap_api_key()
|
||||||
|
bootstrap_users()
|
||||||
|
bootstrap_stale_remote_actions()
|
||||||
|
|
||||||
|
stop_scan = asyncio.Event()
|
||||||
|
scan_task: asyncio.Task | None = None
|
||||||
|
if settings.sac_host_silence_scan_enabled:
|
||||||
|
from app.jobs.host_silence_background import host_silence_scan_loop
|
||||||
|
|
||||||
|
scan_task = asyncio.create_task(host_silence_scan_loop(stop_scan))
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
if scan_task is not None:
|
||||||
|
stop_scan.set()
|
||||||
|
scan_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await scan_task
|
||||||
|
logger.info("%s — application shutdown", APP_VERSION_LABEL)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
if logging.getLogger().handlers:
|
||||||
|
return
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(levelname)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
configure_logging()
|
||||||
|
settings = get_settings()
|
||||||
|
app = FastAPI(
|
||||||
|
title="Security Alert Center",
|
||||||
|
version=APP_VERSION,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||||
|
wildcard = origins == ["*"]
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=origins if not wildcard else ["*"],
|
||||||
|
allow_credentials=not wildcard,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
IngestBodySizeLimitMiddleware,
|
||||||
|
max_bytes=settings.sac_ingest_max_body_bytes,
|
||||||
|
)
|
||||||
|
from app.api.v1.health import router as health_router
|
||||||
|
|
||||||
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
|
app.include_router(health_router)
|
||||||
|
|
||||||
|
frontend_dist = ROOT / "frontend" / "dist"
|
||||||
|
if frontend_dist.is_dir():
|
||||||
|
assets_dir = frontend_dist / "assets"
|
||||||
|
if assets_dir.is_dir():
|
||||||
|
app.mount(
|
||||||
|
"/assets",
|
||||||
|
StaticFiles(directory=str(assets_dir)),
|
||||||
|
name="ui-assets",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/{spa_path:path}")
|
||||||
|
def spa_fallback(spa_path: str) -> FileResponse:
|
||||||
|
if spa_path.startswith("api/"):
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
dist_root = frontend_dist.resolve()
|
||||||
|
if spa_path:
|
||||||
|
candidate = (frontend_dist / spa_path).resolve()
|
||||||
|
try:
|
||||||
|
candidate.relative_to(dist_root)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found") from None
|
||||||
|
if candidate.is_file():
|
||||||
|
return FileResponse(candidate)
|
||||||
|
index = frontend_dist / "index.html"
|
||||||
|
if not index.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="UI not built")
|
||||||
|
return FileResponse(index)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Limit POST /api/v1/events body size (matches nginx client_max_body_size)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
|
class IngestBodySizeLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
def __init__(self, app, *, max_bytes: int = 2_097_152) -> None:
|
||||||
|
super().__init__(app)
|
||||||
|
self._max_bytes = max_bytes
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if request.method == "POST" and request.url.path.rstrip("/") == "/api/v1/events":
|
||||||
|
content_length = request.headers.get("content-length")
|
||||||
|
if content_length:
|
||||||
|
try:
|
||||||
|
size = int(content_length)
|
||||||
|
except ValueError:
|
||||||
|
size = 0
|
||||||
|
if size > self._max_bytes:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=413,
|
||||||
|
content={"detail": f"Request body too large (max {self._max_bytes} bytes)"},
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from app.models.agent_command import AgentCommand
|
||||||
|
from app.models.api_key import ApiKey
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.host import Host
|
||||||
|
from app.models.notification_channel import NotificationChannel
|
||||||
|
from app.models.notification_cooldown import NotificationCooldown
|
||||||
|
from app.models.notification_policy import NotificationPolicy
|
||||||
|
from app.models.problem import Problem, ProblemEvent
|
||||||
|
from app.models.login_attempt import LoginAttempt
|
||||||
|
from app.models.ui_settings import UiSettings
|
||||||
|
from app.models.event_severity_override import EventSeverityOverride
|
||||||
|
from app.models.event_type_visibility import EventTypeVisibility
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.mobile_settings import MobileSettings
|
||||||
|
from app.models.mobile_enrollment_code import MobileEnrollmentCode
|
||||||
|
from app.models.mobile_device import MobileDevice
|
||||||
|
from app.models.mobile_refresh_token import MobileRefreshToken
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ApiKey",
|
||||||
|
"AgentCommand",
|
||||||
|
"Event",
|
||||||
|
"Host",
|
||||||
|
"NotificationChannel",
|
||||||
|
"NotificationCooldown",
|
||||||
|
"NotificationPolicy",
|
||||||
|
"Problem",
|
||||||
|
"ProblemEvent",
|
||||||
|
"User",
|
||||||
|
"EventSeverityOverride",
|
||||||
|
"EventTypeVisibility",
|
||||||
|
"LoginAttempt",
|
||||||
|
"UiSettings",
|
||||||
|
"MobileSettings",
|
||||||
|
"MobileEnrollmentCode",
|
||||||
|
"MobileDevice",
|
||||||
|
"MobileRefreshToken",
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommand(Base):
|
||||||
|
__tablename__ = "agent_commands"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
command_uuid: Mapped[str] = mapped_column(String(36), unique=True, index=True)
|
||||||
|
host_id: Mapped[int] = mapped_column(ForeignKey("hosts.id", ondelete="CASCADE"), index=True)
|
||||||
|
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
command_type: Mapped[str] = mapped_column(String(32))
|
||||||
|
params: Mapped[dict | None] = mapped_column(JSONB, default=dict)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
|
||||||
|
result_stdout: Mapped[str | None] = mapped_column(Text)
|
||||||
|
result_stderr: Mapped[str | None] = mapped_column(Text)
|
||||||
|
requested_by: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
host: Mapped["Host"] = relationship(back_populates="agent_commands")
|
||||||
|
event: Mapped["Event | None"] = relationship()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(Base):
|
||||||
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
|
key_prefix: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
key_hash: Mapped[str] = mapped_column(String(128), unique=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Event(Base):
|
||||||
|
__tablename__ = "events"
|
||||||
|
__table_args__ = (UniqueConstraint("event_id", name="uq_events_event_id"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
event_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||||
|
host_id: Mapped[int] = mapped_column(ForeignKey("hosts.id"), index=True)
|
||||||
|
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
category: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
type: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
severity: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
summary: Mapped[str] = mapped_column(Text)
|
||||||
|
details: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
raw: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
dedup_key: Mapped[str | None] = mapped_column(String(512), index=True)
|
||||||
|
correlation_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
|
payload: Mapped[dict] = mapped_column(JSONB)
|
||||||
|
|
||||||
|
host: Mapped["Host"] = relationship(back_populates="events")
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeverityOverride(Base):
|
||||||
|
__tablename__ = "event_severity_overrides"
|
||||||
|
|
||||||
|
event_type: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
severity: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EventTypeVisibility(Base):
|
||||||
|
"""Per event-type UI visibility (default: show in events lists)."""
|
||||||
|
|
||||||
|
__tablename__ = "event_type_visibility"
|
||||||
|
|
||||||
|
event_type: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
show_in_events: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Host(Base):
|
||||||
|
__tablename__ = "hosts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
agent_instance_id: Mapped[str | None] = mapped_column(String(128), unique=True, index=True)
|
||||||
|
hostname: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
os_family: Mapped[str] = mapped_column(String(32))
|
||||||
|
os_version: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
product: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
product_version: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
ipv4: Mapped[str | None] = mapped_column(String(45))
|
||||||
|
ipv6: Mapped[str | None] = mapped_column(String(45))
|
||||||
|
tags: Mapped[list | None] = mapped_column(JSONB, default=list)
|
||||||
|
inventory: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
inventory_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||||
|
ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Optional override for SSH/WinRM (home PCs, unique local admin). Empty → global Settings.
|
||||||
|
mgmt_user: Mapped[str | None] = mapped_column(String(256))
|
||||||
|
mgmt_password: Mapped[str | None] = mapped_column(Text)
|
||||||
|
pending_agent_update: Mapped[bool] = mapped_column(default=False, server_default="false")
|
||||||
|
pending_update_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
pending_update_target_version: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
agent_config: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
agent_config_revision: Mapped[int] = mapped_column(default=0, server_default="0")
|
||||||
|
agent_update_state: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
agent_update_last_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
agent_update_last_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
remote_action: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
events: Mapped[list["Event"]] = relationship(back_populates="host")
|
||||||
|
agent_commands: Mapped[list["AgentCommand"]] = relationship(back_populates="host")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LoginAttempt(Base):
|
||||||
|
__tablename__ = "login_attempts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
ip_address: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDevice(Base):
|
||||||
|
__tablename__ = "mobile_devices"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("sac_users.id", ondelete="CASCADE"), index=True)
|
||||||
|
device_uuid: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
platform: Mapped[str] = mapped_column(String(32), default="android")
|
||||||
|
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
fcm_token: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
fcm_token_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
enrollment_code_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("mobile_enrollment_codes.id", ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
enrolled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MobileEnrollmentCode(Base):
|
||||||
|
__tablename__ = "mobile_enrollment_codes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
code_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
code_prefix: Mapped[str] = mapped_column(String(16))
|
||||||
|
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("sac_users.id", ondelete="SET NULL"))
|
||||||
|
target_user_id: Mapped[int | None] = mapped_column(ForeignKey("sac_users.id", ondelete="SET NULL"))
|
||||||
|
login_mode: Mapped[str] = mapped_column(String(16), default="password")
|
||||||
|
max_uses: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
use_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MobileRefreshToken(Base):
|
||||||
|
__tablename__ = "mobile_refresh_tokens"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
device_id: Mapped[int] = mapped_column(ForeignKey("mobile_devices.id", ondelete="CASCADE"))
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy import Boolean, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
MOBILE_SETTINGS_ROW_ID = 1
|
||||||
|
|
||||||
|
LOGIN_MODE_PASSWORD = "password"
|
||||||
|
LOGIN_MODE_CODE_ONLY = "code_only"
|
||||||
|
LOGIN_MODES = frozenset({LOGIN_MODE_PASSWORD, LOGIN_MODE_CODE_ONLY})
|
||||||
|
|
||||||
|
|
||||||
|
class MobileSettings(Base):
|
||||||
|
"""Singleton: глобальные настройки мобильного клиента Seaca."""
|
||||||
|
|
||||||
|
__tablename__ = "mobile_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
devices_allowed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
max_devices_per_user: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
min_app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationChannel(Base):
|
||||||
|
__tablename__ = "notification_channels"
|
||||||
|
|
||||||
|
channel: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(default=False)
|
||||||
|
min_severity: Mapped[str] = mapped_column(String(16), default="warning")
|
||||||
|
bot_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
chat_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
webhook_secret_header: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
webhook_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
smtp_host: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
smtp_port: Mapped[int | None] = mapped_column(nullable=True)
|
||||||
|
smtp_user: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
smtp_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
mail_from: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
mail_to: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
smtp_starttls: Mapped[bool] = mapped_column(default=True)
|
||||||
|
smtp_ssl: Mapped[bool] = mapped_column(default=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationCooldown(Base):
|
||||||
|
"""Последняя отправка оповещения по ключу (dedup_key / fingerprint)."""
|
||||||
|
|
||||||
|
__tablename__ = "notification_cooldown"
|
||||||
|
|
||||||
|
cooldown_key: Mapped[str] = mapped_column(String(512), primary_key=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(16)) # event | problem
|
||||||
|
last_notified_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import Boolean, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
POLICY_ROW_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationPolicy(Base):
|
||||||
|
"""Singleton: глобальное правило severity → каналы (F-NOT-02 урезанно)."""
|
||||||
|
|
||||||
|
__tablename__ = "notification_policy"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
min_severity: Mapped[str] = mapped_column(String(16), default="warning")
|
||||||
|
use_telegram: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
use_webhook: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
use_email: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
use_mobile: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Problem(Base):
|
||||||
|
__tablename__ = "problems"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
host_id: Mapped[int | None] = mapped_column(ForeignKey("hosts.id"), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
summary: Mapped[str] = mapped_column(Text)
|
||||||
|
severity: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), index=True, default="open")
|
||||||
|
resolved_by: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
rule_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
fingerprint: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
event_count: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
host: Mapped["Host | None"] = relationship()
|
||||||
|
event_links: Mapped[list["ProblemEvent"]] = relationship(back_populates="problem")
|
||||||
|
|
||||||
|
|
||||||
|
class ProblemEvent(Base):
|
||||||
|
__tablename__ = "problem_events"
|
||||||
|
|
||||||
|
problem_id: Mapped[int] = mapped_column(ForeignKey("problems.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
|
||||||
|
problem: Mapped["Problem"] = relationship(back_populates="event_links")
|
||||||
|
event: Mapped["Event"] = relationship()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
UI_SETTINGS_ROW_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
class UiSettings(Base):
|
||||||
|
"""Singleton: глобальные настройки интерфейса SAC."""
|
||||||
|
|
||||||
|
__tablename__ = "ui_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
show_sidebar_system_stats: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
win_admin_user: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
win_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
linux_admin_user: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
linux_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
agent_update_mode: Mapped[str] = mapped_column(String(16), default="gpo", nullable=False)
|
||||||
|
agent_update_fallback_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
agent_update_fallback_minutes: Mapped[int] = mapped_column(default=15, nullable=False)
|
||||||
|
agent_recommended_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
agent_recommended_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
agent_min_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
agent_min_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
win_agent_update_script: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
agent_rdp_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
agent_ssh_git_repo_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
agent_git_branch: Mapped[str] = mapped_column(String(64), default="main", nullable=False)
|
||||||
|
agent_git_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
agent_git_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
agent_git_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
login_ip_whitelist: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
login_sync_fail2ban: Mapped[bool] = mapped_column(default=False, server_default="false", nullable=False)
|
||||||
|
auto_rdp_flap_disconnect: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false", nullable=False
|
||||||
|
)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
USER_ROLE_ADMIN = "admin"
|
||||||
|
USER_ROLE_MONITOR = "monitor"
|
||||||
|
USER_ROLES = frozenset({USER_ROLE_ADMIN, USER_ROLE_MONITOR})
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "sac_users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(128))
|
||||||
|
role: Mapped[str] = mapped_column(String(16), default=USER_ROLE_MONITOR)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class HostSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
hostname: str
|
||||||
|
display_name: str | None
|
||||||
|
os_family: str
|
||||||
|
os_version: str | None = None
|
||||||
|
product: str
|
||||||
|
product_version: str | None
|
||||||
|
ipv4: str | None
|
||||||
|
last_seen_at: datetime
|
||||||
|
event_count: int | None = None
|
||||||
|
last_heartbeat_at: datetime | None = None
|
||||||
|
last_daily_report_at: datetime | None = None
|
||||||
|
last_inventory_at: datetime | None = None
|
||||||
|
agent_status: str = "unknown"
|
||||||
|
version_status: str = "unknown"
|
||||||
|
pending_agent_update: bool = False
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class HostDetail(HostSummary):
|
||||||
|
agent_instance_id: str | None = None
|
||||||
|
ipv6: str | None = None
|
||||||
|
tags: list | None = None
|
||||||
|
use_sac_mode: str | None = None
|
||||||
|
inventory: dict | None = None
|
||||||
|
inventory_updated_at: datetime | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
ssh_admin_ok: bool | None = None
|
||||||
|
ssh_admin_checked_at: datetime | None = None
|
||||||
|
pending_update_requested_at: datetime | None = None
|
||||||
|
pending_update_target_version: str | None = None
|
||||||
|
agent_config_revision: int = 0
|
||||||
|
agent_config: dict | None = None
|
||||||
|
agent_update_state: str | None = None
|
||||||
|
agent_update_last_at: datetime | None = None
|
||||||
|
agent_update_last_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostListResponse(BaseModel):
|
||||||
|
items: list[HostSummary]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
latest_agent_versions: dict[str, str] = Field(default_factory=dict)
|
||||||
|
reference_agent_versions: dict[str, str] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Эталон для «устарела»: max(git latest, min, max в fleet)",
|
||||||
|
)
|
||||||
|
git_latest_rdp_version: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Последняя версия rdp-login-monitor из git (version.txt / main)",
|
||||||
|
)
|
||||||
|
git_latest_ssh_version: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Последняя версия ssh-monitor из git (version.txt / main)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EventSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
event_id: str
|
||||||
|
host_id: int
|
||||||
|
hostname: str
|
||||||
|
display_name: str | None = None
|
||||||
|
product_version: str | None = None
|
||||||
|
occurred_at: datetime
|
||||||
|
received_at: datetime
|
||||||
|
category: str
|
||||||
|
type: str
|
||||||
|
severity: str
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
actor_user: str | None = None
|
||||||
|
session_duration_sec: int | None = None
|
||||||
|
rdg_flap: bool = False
|
||||||
|
rdg_flap_pair_event_id: int | None = None
|
||||||
|
rdg_flap_qwinsta_event_id: int | None = None
|
||||||
|
rdg_access_path: str | None = None
|
||||||
|
rdg_qwinsta_enabled: bool = False
|
||||||
|
session_terminated: bool = False
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class EventDetail(EventSummary):
|
||||||
|
details: dict | None = None
|
||||||
|
raw: dict | None = None
|
||||||
|
dedup_key: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
payload: dict
|
||||||
|
|
||||||
|
|
||||||
|
class EventListResponse(BaseModel):
|
||||||
|
items: list[EventSummary]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Fail-fast checks for insecure production defaults."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("sac")
|
||||||
|
|
||||||
|
_WEAK_JWT_SECRETS = frozenset(
|
||||||
|
{
|
||||||
|
"",
|
||||||
|
"change-me-in-production",
|
||||||
|
"change-me-openssl-rand-hex-32",
|
||||||
|
"CHANGE_ME_openssl_rand_hex_32",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local_dev_url(public_url: str) -> bool:
|
||||||
|
parsed = urlparse(public_url.strip() or "http://localhost:8000")
|
||||||
|
host = (parsed.hostname or "").lower()
|
||||||
|
return host in {"localhost", "127.0.0.1", "::1", "testserver"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_security_settings(settings: Settings) -> None:
|
||||||
|
"""Raise on dangerous defaults when SAC_SECURITY_ENFORCE=true."""
|
||||||
|
if not settings.sac_security_enforce:
|
||||||
|
return
|
||||||
|
|
||||||
|
if settings.jwt_secret in _WEAK_JWT_SECRETS:
|
||||||
|
raise RuntimeError(
|
||||||
|
"JWT_SECRET is missing or uses an insecure default. "
|
||||||
|
"Set a random value in sac-api.env (openssl rand -hex 32)."
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.cors_origins.strip() == "*" and not _is_local_dev_url(settings.sac_public_url):
|
||||||
|
raise RuntimeError(
|
||||||
|
"CORS_ORIGINS=* is not allowed with SAC_SECURITY_ENFORCE=true on non-local SAC_PUBLIC_URL. "
|
||||||
|
"Set CORS_ORIGINS to your UI origin, e.g. https://sac.example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def warn_relaxed_security(settings: Settings) -> None:
|
||||||
|
if settings.sac_security_enforce:
|
||||||
|
return
|
||||||
|
if settings.jwt_secret in _WEAK_JWT_SECRETS:
|
||||||
|
logger.warning("JWT_SECRET uses insecure default — set a strong secret before production")
|
||||||
|
if settings.cors_origins.strip() == "*":
|
||||||
|
logger.warning("CORS_ORIGINS=* — restrict to explicit origins in production")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Queue and resolve agent commands (legacy poll path + helpers)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import AgentCommand, Host
|
||||||
|
from app.services.win_admin_settings import (
|
||||||
|
get_effective_win_admin_config,
|
||||||
|
get_effective_win_admin_for_host,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _win_admin_configured() -> bool:
|
||||||
|
return get_effective_win_admin_config().configured
|
||||||
|
|
||||||
|
|
||||||
|
def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dict[str, str] | None:
|
||||||
|
if host is not None and db is not None:
|
||||||
|
cfg = get_effective_win_admin_for_host(db, host)
|
||||||
|
else:
|
||||||
|
cfg = get_effective_win_admin_config(db)
|
||||||
|
if not cfg.configured:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"user": cfg.user,
|
||||||
|
"password": cfg.password,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def command_to_dict(
|
||||||
|
cmd: AgentCommand,
|
||||||
|
*,
|
||||||
|
include_run_as: bool = False,
|
||||||
|
db: Session | None = None,
|
||||||
|
host: Host | None = None,
|
||||||
|
) -> dict:
|
||||||
|
out = {
|
||||||
|
"id": cmd.command_uuid,
|
||||||
|
"type": cmd.command_type,
|
||||||
|
"params": cmd.params if isinstance(cmd.params, dict) else {},
|
||||||
|
"status": cmd.status,
|
||||||
|
"result_stdout": cmd.result_stdout,
|
||||||
|
"result_stderr": cmd.result_stderr,
|
||||||
|
"created_at": cmd.created_at.isoformat() if cmd.created_at else None,
|
||||||
|
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
|
||||||
|
}
|
||||||
|
if include_run_as and cmd.status == "pending":
|
||||||
|
run_as = win_admin_run_as(db, host)
|
||||||
|
if run_as:
|
||||||
|
out["run_as"] = run_as
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def command_response_fields(cmd: AgentCommand) -> dict:
|
||||||
|
params = cmd.params if isinstance(cmd.params, dict) else {}
|
||||||
|
return {
|
||||||
|
"target": params.get("winrm_target") or params.get("client_hostname"),
|
||||||
|
"client_hostname": params.get("client_hostname"),
|
||||||
|
"internal_ip": params.get("internal_ip"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(AgentCommand)
|
||||||
|
.where(
|
||||||
|
AgentCommand.host_id == host.id,
|
||||||
|
AgentCommand.status == "pending",
|
||||||
|
)
|
||||||
|
.order_by(AgentCommand.created_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_host_by_agent_instance_id(db: Session, agent_instance_id: str) -> Host | None:
|
||||||
|
if not agent_instance_id.strip():
|
||||||
|
return None
|
||||||
|
return db.scalar(select(Host).where(Host.agent_instance_id == agent_instance_id.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def complete_command(
|
||||||
|
db: Session,
|
||||||
|
command_uuid: str,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
stdout: str | None = None,
|
||||||
|
stderr: str | None = None,
|
||||||
|
) -> AgentCommand | None:
|
||||||
|
cmd = db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
|
||||||
|
if cmd is None:
|
||||||
|
return None
|
||||||
|
if cmd.status != "pending":
|
||||||
|
return cmd
|
||||||
|
cmd.status = status
|
||||||
|
cmd.result_stdout = stdout
|
||||||
|
cmd.result_stderr = stderr
|
||||||
|
cmd.completed_at = datetime.now(timezone.utc)
|
||||||
|
db.flush()
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def get_command_by_uuid(db: Session, command_uuid: str) -> AgentCommand | None:
|
||||||
|
return db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""Fetch latest agent release versions from git repositories."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig
|
||||||
|
from app.services.agent_version import parse_agent_version
|
||||||
|
|
||||||
|
_SCRIPT_VERSION_RE = re.compile(r'\$ScriptVersion\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE)
|
||||||
|
_SSH_VERSION_RE = re.compile(r'^SSH_MONITOR_VERSION=["\']([^"\']+)["\']', re.MULTILINE)
|
||||||
|
|
||||||
|
_MEMORY_CACHE: dict[str, tuple[dict[str, str], float, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GitReleaseVersions:
|
||||||
|
versions: dict[str, str]
|
||||||
|
fetched_at: datetime | None
|
||||||
|
from_cache: bool
|
||||||
|
errors: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_git_repo_url(raw: str) -> str:
|
||||||
|
text = (raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if not text.startswith(("http://", "https://", "git@")):
|
||||||
|
text = f"https://{text.lstrip('/')}"
|
||||||
|
if text.startswith("git@"):
|
||||||
|
return text
|
||||||
|
if not text.endswith(".git"):
|
||||||
|
text = f"{text.rstrip('/')}.git"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def git_repo_url_with_auth(repo_url: str, token: str) -> str:
|
||||||
|
normalized = normalize_git_repo_url(repo_url)
|
||||||
|
if not token or not normalized.startswith("https://"):
|
||||||
|
return normalized
|
||||||
|
parsed = urlparse(normalized)
|
||||||
|
host = parsed.netloc
|
||||||
|
path = parsed.path or ""
|
||||||
|
return f"https://oauth2:{token}@{host}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(cfg: AgentUpdateConfig) -> str:
|
||||||
|
parts = [
|
||||||
|
cfg.rdp_git_repo_url,
|
||||||
|
cfg.ssh_git_repo_url,
|
||||||
|
cfg.git_branch,
|
||||||
|
]
|
||||||
|
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_text(path: Path) -> str | None:
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
return path.read_text(encoding="utf-8-sig")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rdp_version_from_files(version_txt: str | None, login_monitor_ps1: str | None) -> str | None:
|
||||||
|
if version_txt:
|
||||||
|
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
|
||||||
|
if parse_agent_version(first_line):
|
||||||
|
return first_line
|
||||||
|
if login_monitor_ps1:
|
||||||
|
match = _SCRIPT_VERSION_RE.search(login_monitor_ps1)
|
||||||
|
if match:
|
||||||
|
candidate = match.group(1).strip()
|
||||||
|
if parse_agent_version(candidate):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ssh_version_from_files(ssh_monitor: str | None, version_txt: str | None) -> str | None:
|
||||||
|
if ssh_monitor:
|
||||||
|
match = _SSH_VERSION_RE.search(ssh_monitor)
|
||||||
|
if match:
|
||||||
|
candidate = match.group(1).strip()
|
||||||
|
if parse_agent_version(candidate):
|
||||||
|
return candidate
|
||||||
|
if version_txt:
|
||||||
|
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
|
||||||
|
if parse_agent_version(first_line):
|
||||||
|
return first_line
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_product_version_from_dir(repo_dir: Path, product: str) -> str | None:
|
||||||
|
if product == PRODUCT_RDP:
|
||||||
|
return parse_rdp_version_from_files(
|
||||||
|
_read_text(repo_dir / "version.txt"),
|
||||||
|
_read_text(repo_dir / "Login_Monitor.ps1"),
|
||||||
|
)
|
||||||
|
if product == PRODUCT_SSH:
|
||||||
|
return parse_ssh_version_from_files(
|
||||||
|
_read_text(repo_dir / "ssh-monitor"),
|
||||||
|
_read_text(repo_dir / "version.txt"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(args: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_slug(repo_url: str) -> str:
|
||||||
|
normalized = normalize_git_repo_url(repo_url)
|
||||||
|
name = normalized.rstrip("/").split("/")[-1]
|
||||||
|
if name.endswith(".git"):
|
||||||
|
name = name[:-4]
|
||||||
|
digest = hashlib.sha256(normalized.encode()).hexdigest()[:12]
|
||||||
|
return f"{name}-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def clone_or_update_repo(repo_url: str, branch: str, cache_dir: Path, token: str = "") -> Path:
|
||||||
|
clone_url = git_repo_url_with_auth(repo_url, token)
|
||||||
|
normalized = normalize_git_repo_url(repo_url)
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("empty repo url")
|
||||||
|
|
||||||
|
branch_name = (branch or "main").strip() or "main"
|
||||||
|
target = cache_dir / _repo_slug(normalized)
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if target.is_dir() and (target / ".git").is_dir():
|
||||||
|
fetch = _run_git(["fetch", "--depth", "1", "origin", branch_name], cwd=target)
|
||||||
|
if fetch.returncode != 0:
|
||||||
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
checkout = _run_git(["checkout", "FETCH_HEAD"], cwd=target)
|
||||||
|
if checkout.returncode == 0:
|
||||||
|
return target
|
||||||
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
|
||||||
|
clone = _run_git(
|
||||||
|
["clone", "--depth", "1", "--branch", branch_name, clone_url, str(target)],
|
||||||
|
)
|
||||||
|
if clone.returncode != 0:
|
||||||
|
err = (clone.stderr or clone.stdout or "git clone failed").strip()
|
||||||
|
raise RuntimeError(err)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_product_version(repo_url: str, branch: str, product: str, cache_dir: Path, token: str = "") -> str:
|
||||||
|
repo_dir = clone_or_update_repo(repo_url, branch, cache_dir, token=token)
|
||||||
|
version = parse_product_version_from_dir(repo_dir, product)
|
||||||
|
if not version:
|
||||||
|
raise RuntimeError(f"version not found in {repo_url} ({product})")
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def recommended_from_git(cfg: AgentUpdateConfig, git_versions: dict[str, str], product: str) -> str:
|
||||||
|
git_version = (git_versions.get(product) or "").strip()
|
||||||
|
if git_version:
|
||||||
|
return git_version
|
||||||
|
manual = cfg.recommended_for_product(product)
|
||||||
|
return manual
|
||||||
|
|
||||||
|
|
||||||
|
def _ttl_seconds() -> int:
|
||||||
|
settings = get_settings()
|
||||||
|
return max(60, int(settings.sac_agent_git_cache_ttl_minutes) * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_dir() -> Path:
|
||||||
|
settings = get_settings()
|
||||||
|
return Path(settings.sac_agent_git_cache_dir or "/tmp/sac-agent-repos")
|
||||||
|
|
||||||
|
|
||||||
|
def _db_cache_fresh(row: UiSettings | None, cfg: AgentUpdateConfig) -> bool:
|
||||||
|
if row is None or row.agent_git_fetched_at is None:
|
||||||
|
return False
|
||||||
|
fetched_at = row.agent_git_fetched_at
|
||||||
|
if fetched_at.tzinfo is None:
|
||||||
|
fetched_at = fetched_at.replace(tzinfo=timezone.utc)
|
||||||
|
age = (datetime.now(timezone.utc) - fetched_at).total_seconds()
|
||||||
|
if age > _ttl_seconds():
|
||||||
|
return False
|
||||||
|
if (row.agent_rdp_git_repo_url or "").strip() != cfg.rdp_git_repo_url:
|
||||||
|
return False
|
||||||
|
if (row.agent_ssh_git_repo_url or "").strip() != cfg.ssh_git_repo_url:
|
||||||
|
return False
|
||||||
|
if (row.agent_git_branch or "main").strip() != cfg.git_branch:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _versions_from_db_row(row: UiSettings) -> dict[str, str]:
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
rdp = (row.agent_git_rdp_version or "").strip()
|
||||||
|
ssh = (row.agent_git_ssh_version or "").strip()
|
||||||
|
if rdp:
|
||||||
|
out[PRODUCT_RDP] = rdp
|
||||||
|
if ssh:
|
||||||
|
out[PRODUCT_SSH] = ssh
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_db_cache(db: Session, cfg: AgentUpdateConfig, versions: dict[str, str]) -> datetime:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
|
if row is None:
|
||||||
|
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
|
||||||
|
db.add(row)
|
||||||
|
row.agent_rdp_git_repo_url = cfg.rdp_git_repo_url or None
|
||||||
|
row.agent_ssh_git_repo_url = cfg.ssh_git_repo_url or None
|
||||||
|
row.agent_git_branch = cfg.git_branch or "main"
|
||||||
|
row.agent_git_rdp_version = versions.get(PRODUCT_RDP) or None
|
||||||
|
row.agent_git_ssh_version = versions.get(PRODUCT_SSH) or None
|
||||||
|
row.agent_git_fetched_at = now
|
||||||
|
db.commit()
|
||||||
|
return now
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_release_versions(
|
||||||
|
cfg: AgentUpdateConfig,
|
||||||
|
*,
|
||||||
|
db: Session | None = None,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> GitReleaseVersions:
|
||||||
|
key = _cache_key(cfg)
|
||||||
|
now_ts = time.time()
|
||||||
|
ttl = _ttl_seconds()
|
||||||
|
|
||||||
|
if not force_refresh:
|
||||||
|
cached = _MEMORY_CACHE.get(key)
|
||||||
|
if cached and now_ts - cached[1] <= ttl:
|
||||||
|
fetched_at = datetime.fromtimestamp(cached[1], tz=timezone.utc)
|
||||||
|
return GitReleaseVersions(versions=dict(cached[0]), fetched_at=fetched_at, from_cache=True)
|
||||||
|
|
||||||
|
if db is not None:
|
||||||
|
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
|
if _db_cache_fresh(row, cfg):
|
||||||
|
versions = _versions_from_db_row(row)
|
||||||
|
if versions:
|
||||||
|
_MEMORY_CACHE[key] = (versions, row.agent_git_fetched_at.timestamp(), key)
|
||||||
|
return GitReleaseVersions(
|
||||||
|
versions=versions,
|
||||||
|
fetched_at=row.agent_git_fetched_at,
|
||||||
|
from_cache=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
cache_dir = _cache_dir()
|
||||||
|
token = (settings.sac_agent_git_token or "").strip()
|
||||||
|
branch = cfg.git_branch or "main"
|
||||||
|
versions: dict[str, str] = {}
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
product_repo = {
|
||||||
|
PRODUCT_RDP: cfg.rdp_git_repo_url,
|
||||||
|
PRODUCT_SSH: cfg.ssh_git_repo_url,
|
||||||
|
}
|
||||||
|
for product, repo_url in product_repo.items():
|
||||||
|
if not repo_url:
|
||||||
|
errors[product] = "repo url not configured"
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
versions[product] = fetch_product_version(repo_url, branch, product, cache_dir, token=token)
|
||||||
|
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
|
||||||
|
errors[product] = str(exc).strip() or "fetch failed"
|
||||||
|
|
||||||
|
fetched_at: datetime | None = None
|
||||||
|
if versions:
|
||||||
|
fetched_at = datetime.now(timezone.utc)
|
||||||
|
_MEMORY_CACHE[key] = (dict(versions), fetched_at.timestamp(), key)
|
||||||
|
if db is not None:
|
||||||
|
fetched_at = _persist_db_cache(db, cfg, versions)
|
||||||
|
|
||||||
|
if not versions and db is not None:
|
||||||
|
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
|
if row is not None:
|
||||||
|
stale = _versions_from_db_row(row)
|
||||||
|
if stale:
|
||||||
|
return GitReleaseVersions(
|
||||||
|
versions=stale,
|
||||||
|
fetched_at=row.agent_git_fetched_at,
|
||||||
|
from_cache=True,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
return GitReleaseVersions(
|
||||||
|
versions=versions,
|
||||||
|
fetched_at=fetched_at,
|
||||||
|
from_cache=False,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Desired agent config per host (poll delivery)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
|
from app.models import Host
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||||
|
|
||||||
|
RDP_CONFIG_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"ServerDisplayName",
|
||||||
|
"EnableRcmShadowControlMonitoring",
|
||||||
|
"EnableWinRmInboundMonitoring",
|
||||||
|
"EnableAdminShareMonitoring",
|
||||||
|
"GetInventory",
|
||||||
|
"SacCommandPollIntervalSec",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SSH_CONFIG_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"SERVER_DISPLAY_NAME",
|
||||||
|
"HEARTBEAT_INTERVAL",
|
||||||
|
"DAILY_REPORT_ENABLED",
|
||||||
|
"UseSAC",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_keys(host: Host) -> frozenset[str]:
|
||||||
|
product = (host.product or "").strip()
|
||||||
|
if product == PRODUCT_RDP or (host.os_family or "").strip().lower() == "windows":
|
||||||
|
return RDP_CONFIG_KEYS
|
||||||
|
if product == PRODUCT_SSH or (host.os_family or "").strip().lower() == "linux":
|
||||||
|
return SSH_CONFIG_KEYS
|
||||||
|
return frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
def get_host_agent_settings(host: Host) -> dict:
|
||||||
|
raw = host.agent_config if isinstance(host.agent_config, dict) else {}
|
||||||
|
allowed = _allowed_keys(host)
|
||||||
|
if not allowed:
|
||||||
|
return {}
|
||||||
|
return {k: v for k, v in raw.items() if k in allowed}
|
||||||
|
|
||||||
|
|
||||||
|
def build_agent_config_payload(host: Host) -> dict:
|
||||||
|
settings = get_host_agent_settings(host)
|
||||||
|
if not settings:
|
||||||
|
return {}
|
||||||
|
return {"settings": settings}
|
||||||
|
|
||||||
|
|
||||||
|
def update_host_agent_config(db: Session, host: Host, settings: dict) -> Host:
|
||||||
|
allowed = _allowed_keys(host)
|
||||||
|
if not allowed:
|
||||||
|
raise ValueError("Host product does not support desired agent config")
|
||||||
|
|
||||||
|
current = host.agent_config if isinstance(host.agent_config, dict) else {}
|
||||||
|
merged = dict(current)
|
||||||
|
for key, value in settings.items():
|
||||||
|
if key not in allowed:
|
||||||
|
raise ValueError(f"Unsupported config key: {key}")
|
||||||
|
if value is None:
|
||||||
|
merged.pop(key, None)
|
||||||
|
else:
|
||||||
|
merged[key] = value
|
||||||
|
|
||||||
|
host.agent_config = merged
|
||||||
|
host.agent_config_revision = int(host.agent_config_revision or 0) + 1
|
||||||
|
flag_modified(host, "agent_config")
|
||||||
|
db.flush()
|
||||||
|
return host
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Agent poll payload: commands, desired config, pending update."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Host
|
||||||
|
from app.services.agent_commands import command_to_dict, get_command_for_host_poll
|
||||||
|
from app.services.agent_host_config import build_agent_config_payload
|
||||||
|
from app.services.agent_update_settings import get_effective_agent_update_config
|
||||||
|
|
||||||
|
|
||||||
|
def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]:
|
||||||
|
cfg = get_effective_agent_update_config(db)
|
||||||
|
pending = get_command_for_host_poll(db, host)
|
||||||
|
config_payload = build_agent_config_payload(host)
|
||||||
|
|
||||||
|
update_block: dict[str, Any] = {
|
||||||
|
"requested": bool(host.pending_agent_update),
|
||||||
|
"target_version": host.pending_update_target_version,
|
||||||
|
"source": "sac" if cfg.sac_managed else "gpo",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"config_revision": int(host.agent_config_revision or 0),
|
||||||
|
"config": config_payload,
|
||||||
|
"update": update_block,
|
||||||
|
"commands": [command_to_dict(c, include_run_as=True, db=db, host=host) for c in pending],
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"""Request agent self-update, ingest lifecycle, SSH/WinRM fallback."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Host
|
||||||
|
from app.services.agent_update_types import AgentUpdateFallbackResult
|
||||||
|
from app.services.agent_update_settings import (
|
||||||
|
PRODUCT_RDP,
|
||||||
|
PRODUCT_SSH,
|
||||||
|
AgentUpdateConfig,
|
||||||
|
get_effective_agent_update_config,
|
||||||
|
)
|
||||||
|
from app.services.agent_git_release import get_git_release_versions, recommended_from_git
|
||||||
|
from app.services.agent_version import (
|
||||||
|
host_version_outdated,
|
||||||
|
latest_agent_versions_by_product,
|
||||||
|
)
|
||||||
|
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||||
|
from app.services.ssh_connect import (
|
||||||
|
HostNotLinuxError,
|
||||||
|
HostTargetMissingError,
|
||||||
|
is_linux_host,
|
||||||
|
iter_ssh_targets,
|
||||||
|
run_ssh_monitor_update,
|
||||||
|
)
|
||||||
|
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||||
|
from app.services.winrm_connect import (
|
||||||
|
HostNotWindowsError,
|
||||||
|
HostTargetMissingError as WinRmHostTargetMissingError,
|
||||||
|
is_windows_host,
|
||||||
|
iter_winrm_targets,
|
||||||
|
run_winrm_on_host_targets,
|
||||||
|
run_winrm_rdp_monitor_update,
|
||||||
|
)
|
||||||
|
|
||||||
|
AGENT_UPDATE_STARTED = "agent.update.started"
|
||||||
|
AGENT_UPDATE_SUCCESS = "agent.update.success"
|
||||||
|
AGENT_UPDATE_FAILED = "agent.update.failed"
|
||||||
|
AGENT_UPDATE_TYPES = frozenset(
|
||||||
|
{AGENT_UPDATE_STARTED, AGENT_UPDATE_SUCCESS, AGENT_UPDATE_FAILED}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentUpdateNotAllowedError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_target_version(
|
||||||
|
db: Session,
|
||||||
|
host: Host,
|
||||||
|
cfg: AgentUpdateConfig,
|
||||||
|
*,
|
||||||
|
git_versions: dict[str, str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
product = host.product or ""
|
||||||
|
if git_versions is None:
|
||||||
|
git_versions = get_git_release_versions(cfg, db=db).versions
|
||||||
|
git_target = recommended_from_git(cfg, git_versions, product)
|
||||||
|
if git_target:
|
||||||
|
return git_target
|
||||||
|
latest_map = latest_agent_versions_by_product(db)
|
||||||
|
return latest_map.get(product, "") or (host.product_version or "")
|
||||||
|
|
||||||
|
|
||||||
|
def host_version_status(
|
||||||
|
host: Host,
|
||||||
|
*,
|
||||||
|
cfg: AgentUpdateConfig,
|
||||||
|
latest_map: dict[str, str],
|
||||||
|
git_versions: dict[str, str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
product = host.product or ""
|
||||||
|
if git_versions is None:
|
||||||
|
git_versions = get_git_release_versions(cfg).versions
|
||||||
|
outdated = host_version_outdated(
|
||||||
|
host.product_version,
|
||||||
|
recommended=recommended_from_git(cfg, git_versions, product),
|
||||||
|
fleet_latest=latest_map.get(product, ""),
|
||||||
|
min_version=cfg.min_for_product(product),
|
||||||
|
)
|
||||||
|
if outdated is None:
|
||||||
|
return "unknown"
|
||||||
|
return "outdated" if outdated else "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def request_agent_update(db: Session, host: Host) -> Host:
|
||||||
|
cfg = get_effective_agent_update_config(db)
|
||||||
|
if not cfg.sac_managed:
|
||||||
|
raise AgentUpdateNotAllowedError(
|
||||||
|
"Agent updates from SAC are disabled (mode=gpo). Use GPO/manual update or switch to mode=sac."
|
||||||
|
)
|
||||||
|
if not host.agent_instance_id:
|
||||||
|
raise AgentUpdateNotAllowedError("Host has no agent_instance_id (agent never connected via SAC)")
|
||||||
|
|
||||||
|
target = resolve_target_version(db, host, cfg)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
host.pending_agent_update = True
|
||||||
|
host.pending_update_requested_at = now
|
||||||
|
host.pending_update_target_version = target or None
|
||||||
|
host.agent_update_state = "requested"
|
||||||
|
host.agent_update_last_error = None
|
||||||
|
db.flush()
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_pending(host: Host) -> None:
|
||||||
|
host.pending_agent_update = False
|
||||||
|
host.pending_update_requested_at = None
|
||||||
|
host.pending_update_target_version = None
|
||||||
|
|
||||||
|
|
||||||
|
def process_agent_update_ingest(db: Session, host: Host, event_type: str, details: dict | None) -> None:
|
||||||
|
if event_type not in AGENT_UPDATE_TYPES:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
host.agent_update_last_at = now
|
||||||
|
details = details if isinstance(details, dict) else {}
|
||||||
|
|
||||||
|
if event_type == AGENT_UPDATE_STARTED:
|
||||||
|
host.agent_update_state = "started"
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == AGENT_UPDATE_SUCCESS:
|
||||||
|
host.agent_update_state = "success"
|
||||||
|
host.agent_update_last_error = None
|
||||||
|
_clear_pending(host)
|
||||||
|
version = details.get("product_version") or details.get("version")
|
||||||
|
if isinstance(version, str) and version.strip():
|
||||||
|
host.product_version = version.strip()
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == AGENT_UPDATE_FAILED:
|
||||||
|
host.agent_update_state = "failed"
|
||||||
|
err = details.get("error") or details.get("message")
|
||||||
|
host.agent_update_last_error = str(err)[:2000] if err else "agent.update.failed"
|
||||||
|
_clear_pending(host)
|
||||||
|
|
||||||
|
|
||||||
|
def run_linux_agent_update_fallback(
|
||||||
|
host: Host,
|
||||||
|
cfg_linux,
|
||||||
|
*,
|
||||||
|
repo_url: str | None = None,
|
||||||
|
git_branch: str | None = None,
|
||||||
|
) -> AgentUpdateFallbackResult:
|
||||||
|
if not is_linux_host(host):
|
||||||
|
return AgentUpdateFallbackResult(ok=False, message="Host is not Linux", product_version=None)
|
||||||
|
if not cfg_linux.configured:
|
||||||
|
return AgentUpdateFallbackResult(
|
||||||
|
ok=False,
|
||||||
|
message="Linux SSH admin is not configured",
|
||||||
|
product_version=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
last = AgentUpdateFallbackResult(ok=False, message="SSH fallback failed", product_version=None)
|
||||||
|
try:
|
||||||
|
targets = iter_ssh_targets(host)
|
||||||
|
except (HostNotLinuxError, HostTargetMissingError) as exc:
|
||||||
|
return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None)
|
||||||
|
|
||||||
|
effective_repo = (repo_url or "").strip() or None
|
||||||
|
effective_branch = (git_branch or "main").strip() or "main"
|
||||||
|
for target in targets:
|
||||||
|
result = run_ssh_monitor_update(
|
||||||
|
target=target,
|
||||||
|
user=cfg_linux.user,
|
||||||
|
password=cfg_linux.password,
|
||||||
|
repo_url=effective_repo,
|
||||||
|
git_branch=effective_branch,
|
||||||
|
)
|
||||||
|
last = AgentUpdateFallbackResult(
|
||||||
|
ok=result.ok,
|
||||||
|
message=result.message,
|
||||||
|
product_version=result.agent_version,
|
||||||
|
target=result.target,
|
||||||
|
stdout=result.stdout,
|
||||||
|
stderr=result.stderr,
|
||||||
|
exit_code=result.exit_code,
|
||||||
|
)
|
||||||
|
if result.ok:
|
||||||
|
return last
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
def run_windows_agent_update_fallback(
|
||||||
|
host: Host,
|
||||||
|
cfg_win,
|
||||||
|
script_path: str,
|
||||||
|
*,
|
||||||
|
repo_url: str = "",
|
||||||
|
git_branch: str = "main",
|
||||||
|
) -> AgentUpdateFallbackResult:
|
||||||
|
if not is_windows_host(host):
|
||||||
|
return AgentUpdateFallbackResult(ok=False, message="Host is not Windows", product_version=None)
|
||||||
|
if not cfg_win.configured:
|
||||||
|
return AgentUpdateFallbackResult(
|
||||||
|
ok=False,
|
||||||
|
message="Windows domain admin is not configured",
|
||||||
|
product_version=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
targets = iter_winrm_targets(host)
|
||||||
|
except (HostNotWindowsError, WinRmHostTargetMissingError) as exc:
|
||||||
|
return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None)
|
||||||
|
|
||||||
|
result, attempts = run_winrm_on_host_targets(
|
||||||
|
host,
|
||||||
|
user=cfg_win.user,
|
||||||
|
password=cfg_win.password,
|
||||||
|
action=lambda target: run_winrm_rdp_monitor_update(
|
||||||
|
target=target,
|
||||||
|
user=cfg_win.user,
|
||||||
|
password=cfg_win.password,
|
||||||
|
script_path=script_path,
|
||||||
|
repo_url=repo_url,
|
||||||
|
git_branch=git_branch,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
return AgentUpdateFallbackResult(ok=False, message="WinRM fallback failed", product_version=None)
|
||||||
|
message = result.message
|
||||||
|
if attempts:
|
||||||
|
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||||
|
version = None
|
||||||
|
if result.ok and result.stdout:
|
||||||
|
from app.services.ssh_connect import parse_ssh_monitor_version_text
|
||||||
|
|
||||||
|
version = parse_ssh_monitor_version_text(result.stdout)
|
||||||
|
return AgentUpdateFallbackResult(
|
||||||
|
ok=result.ok,
|
||||||
|
message=message,
|
||||||
|
product_version=version,
|
||||||
|
target=result.target,
|
||||||
|
stdout=result.stdout,
|
||||||
|
stderr=result.stderr,
|
||||||
|
exit_code=result.exit_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbackResult:
|
||||||
|
cfg = get_effective_agent_update_config(db)
|
||||||
|
product = (host.product or "").strip()
|
||||||
|
|
||||||
|
if product == PRODUCT_SSH or is_linux_host(host):
|
||||||
|
linux_cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
|
agent_cfg = get_effective_agent_update_config(db)
|
||||||
|
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
||||||
|
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
||||||
|
result = run_linux_agent_update_fallback(
|
||||||
|
host,
|
||||||
|
linux_cfg,
|
||||||
|
repo_url=repo_url,
|
||||||
|
git_branch=branch,
|
||||||
|
)
|
||||||
|
elif product == PRODUCT_RDP or is_windows_host(host):
|
||||||
|
win_cfg = get_effective_win_admin_for_host(db, host)
|
||||||
|
result = run_windows_agent_update_fallback(
|
||||||
|
host,
|
||||||
|
win_cfg,
|
||||||
|
cfg.win_agent_update_script,
|
||||||
|
repo_url=cfg.rdp_git_repo_url,
|
||||||
|
git_branch=cfg.git_branch,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return AgentUpdateFallbackResult(
|
||||||
|
ok=False,
|
||||||
|
message=f"Unsupported product for fallback update: {product}",
|
||||||
|
product_version=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
host.agent_update_last_at = now
|
||||||
|
if result.ok:
|
||||||
|
host.agent_update_state = "success"
|
||||||
|
host.agent_update_last_error = None
|
||||||
|
_clear_pending(host)
|
||||||
|
if result.product_version:
|
||||||
|
host.product_version = result.product_version
|
||||||
|
if is_linux_host(host):
|
||||||
|
host.ssh_admin_ok = True
|
||||||
|
host.ssh_admin_checked_at = now
|
||||||
|
else:
|
||||||
|
host.agent_update_state = "failed"
|
||||||
|
host.agent_update_last_error = result.message[:2000]
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def process_agent_update_fallbacks(db: Session) -> list[dict]:
|
||||||
|
"""Find stale pending updates and run SSH/WinRM fallback when enabled."""
|
||||||
|
cfg = get_effective_agent_update_config(db)
|
||||||
|
if not cfg.sac_managed or not cfg.fallback_enabled:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=cfg.fallback_after_minutes)
|
||||||
|
rows = db.scalars(
|
||||||
|
select(Host).where(
|
||||||
|
Host.pending_agent_update.is_(True),
|
||||||
|
Host.pending_update_requested_at.isnot(None),
|
||||||
|
Host.pending_update_requested_at <= cutoff,
|
||||||
|
Host.agent_update_state.in_(("requested", "started")),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
results: list[dict] = []
|
||||||
|
for host in rows:
|
||||||
|
result = execute_agent_update_fallback(db, host)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"host_id": host.id,
|
||||||
|
"hostname": host.hostname,
|
||||||
|
"ok": result.ok,
|
||||||
|
"message": result.message,
|
||||||
|
"product_version": result.product_version,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Global agent update policy (DB overrides env)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||||
|
|
||||||
|
PRODUCT_RDP = "rdp-login-monitor"
|
||||||
|
PRODUCT_SSH = "ssh-monitor"
|
||||||
|
AGENT_UPDATE_MODES = frozenset({"gpo", "sac"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentUpdateConfig:
|
||||||
|
mode: str
|
||||||
|
fallback_enabled: bool
|
||||||
|
fallback_after_minutes: int
|
||||||
|
recommended_rdp_version: str
|
||||||
|
recommended_ssh_version: str
|
||||||
|
min_rdp_version: str
|
||||||
|
min_ssh_version: str
|
||||||
|
win_agent_update_script: str
|
||||||
|
rdp_git_repo_url: str
|
||||||
|
ssh_git_repo_url: str
|
||||||
|
git_branch: str
|
||||||
|
source: str # env | db
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sac_managed(self) -> bool:
|
||||||
|
return self.mode == "sac"
|
||||||
|
|
||||||
|
def recommended_for_product(self, product: str) -> str:
|
||||||
|
if product == PRODUCT_RDP:
|
||||||
|
return self.recommended_rdp_version
|
||||||
|
if product == PRODUCT_SSH:
|
||||||
|
return self.recommended_ssh_version
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def min_for_product(self, product: str) -> str:
|
||||||
|
if product == PRODUCT_RDP:
|
||||||
|
return self.min_rdp_version
|
||||||
|
if product == PRODUCT_SSH:
|
||||||
|
return self.min_ssh_version
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_update_from_env() -> AgentUpdateConfig:
|
||||||
|
settings = get_settings()
|
||||||
|
mode = (settings.sac_agent_update_mode or "gpo").strip().lower()
|
||||||
|
if mode not in AGENT_UPDATE_MODES:
|
||||||
|
mode = "gpo"
|
||||||
|
return AgentUpdateConfig(
|
||||||
|
mode=mode,
|
||||||
|
fallback_enabled=settings.sac_agent_update_fallback_enabled,
|
||||||
|
fallback_after_minutes=max(1, int(settings.sac_agent_update_fallback_minutes)),
|
||||||
|
recommended_rdp_version=(settings.sac_agent_recommended_rdp_version or "").strip(),
|
||||||
|
recommended_ssh_version=(settings.sac_agent_recommended_ssh_version or "").strip(),
|
||||||
|
min_rdp_version=(settings.sac_agent_min_rdp_version or "").strip(),
|
||||||
|
min_ssh_version=(settings.sac_agent_min_ssh_version or "").strip(),
|
||||||
|
win_agent_update_script=(settings.sac_win_agent_update_script or "").strip(),
|
||||||
|
rdp_git_repo_url=(settings.sac_agent_rdp_git_repo_url or "").strip(),
|
||||||
|
ssh_git_repo_url=(settings.sac_agent_ssh_git_repo_url or "").strip(),
|
||||||
|
git_branch=(settings.sac_agent_git_branch or "main").strip() or "main",
|
||||||
|
source="env",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_has_agent_update_values(row: UiSettings) -> bool:
|
||||||
|
return any(
|
||||||
|
[
|
||||||
|
(row.agent_update_mode or "").strip() not in ("", "gpo"),
|
||||||
|
row.agent_update_fallback_enabled is not None,
|
||||||
|
row.agent_update_fallback_minutes not in (None, 15),
|
||||||
|
bool((row.agent_recommended_rdp_version or "").strip()),
|
||||||
|
bool((row.agent_recommended_ssh_version or "").strip()),
|
||||||
|
bool((row.agent_min_rdp_version or "").strip()),
|
||||||
|
bool((row.agent_min_ssh_version or "").strip()),
|
||||||
|
bool((row.win_agent_update_script or "").strip()),
|
||||||
|
bool((row.agent_rdp_git_repo_url or "").strip()),
|
||||||
|
bool((row.agent_ssh_git_repo_url or "").strip()),
|
||||||
|
(row.agent_git_branch or "main").strip() not in ("", "main"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_agent_update_config(db: Session | None = None) -> AgentUpdateConfig:
|
||||||
|
if db is None:
|
||||||
|
from app.database import SessionLocal
|
||||||
|
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
return get_effective_agent_update_config(session)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
|
env_cfg = _agent_update_from_env()
|
||||||
|
if row is None:
|
||||||
|
return env_cfg
|
||||||
|
|
||||||
|
mode = (row.agent_update_mode or env_cfg.mode).strip().lower()
|
||||||
|
if mode not in AGENT_UPDATE_MODES:
|
||||||
|
mode = env_cfg.mode
|
||||||
|
|
||||||
|
return AgentUpdateConfig(
|
||||||
|
mode=mode,
|
||||||
|
fallback_enabled=(
|
||||||
|
row.agent_update_fallback_enabled
|
||||||
|
if row.agent_update_fallback_enabled is not None
|
||||||
|
else env_cfg.fallback_enabled
|
||||||
|
),
|
||||||
|
fallback_after_minutes=max(
|
||||||
|
1,
|
||||||
|
int(row.agent_update_fallback_minutes or env_cfg.fallback_after_minutes),
|
||||||
|
),
|
||||||
|
recommended_rdp_version=(row.agent_recommended_rdp_version or "").strip()
|
||||||
|
or env_cfg.recommended_rdp_version,
|
||||||
|
recommended_ssh_version=(row.agent_recommended_ssh_version or "").strip()
|
||||||
|
or env_cfg.recommended_ssh_version,
|
||||||
|
min_rdp_version=(row.agent_min_rdp_version or "").strip() or env_cfg.min_rdp_version,
|
||||||
|
min_ssh_version=(row.agent_min_ssh_version or "").strip() or env_cfg.min_ssh_version,
|
||||||
|
win_agent_update_script=(row.win_agent_update_script or "").strip()
|
||||||
|
or env_cfg.win_agent_update_script,
|
||||||
|
rdp_git_repo_url=(row.agent_rdp_git_repo_url or "").strip() or env_cfg.rdp_git_repo_url,
|
||||||
|
ssh_git_repo_url=(row.agent_ssh_git_repo_url or "").strip() or env_cfg.ssh_git_repo_url,
|
||||||
|
git_branch=(row.agent_git_branch or env_cfg.git_branch).strip() or env_cfg.git_branch,
|
||||||
|
source="db" if _row_has_agent_update_values(row) else env_cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_agent_update_settings(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
mode: str | None = None,
|
||||||
|
fallback_enabled: bool | None = None,
|
||||||
|
fallback_after_minutes: int | None = None,
|
||||||
|
recommended_rdp_version: str | None = None,
|
||||||
|
recommended_ssh_version: str | None = None,
|
||||||
|
min_rdp_version: str | None = None,
|
||||||
|
min_ssh_version: str | None = None,
|
||||||
|
win_agent_update_script: str | None = None,
|
||||||
|
rdp_git_repo_url: str | None = None,
|
||||||
|
ssh_git_repo_url: str | None = None,
|
||||||
|
git_branch: str | None = None,
|
||||||
|
) -> AgentUpdateConfig:
|
||||||
|
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
|
if row is None:
|
||||||
|
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
|
||||||
|
db.add(row)
|
||||||
|
|
||||||
|
if mode is not None:
|
||||||
|
normalized = mode.strip().lower()
|
||||||
|
if normalized not in AGENT_UPDATE_MODES:
|
||||||
|
raise ValueError(f"mode must be one of: {sorted(AGENT_UPDATE_MODES)}")
|
||||||
|
row.agent_update_mode = normalized
|
||||||
|
if fallback_enabled is not None:
|
||||||
|
row.agent_update_fallback_enabled = fallback_enabled
|
||||||
|
if fallback_after_minutes is not None:
|
||||||
|
row.agent_update_fallback_minutes = max(1, int(fallback_after_minutes))
|
||||||
|
if recommended_rdp_version is not None:
|
||||||
|
row.agent_recommended_rdp_version = recommended_rdp_version.strip() or None
|
||||||
|
if recommended_ssh_version is not None:
|
||||||
|
row.agent_recommended_ssh_version = recommended_ssh_version.strip() or None
|
||||||
|
if min_rdp_version is not None:
|
||||||
|
row.agent_min_rdp_version = min_rdp_version.strip() or None
|
||||||
|
if min_ssh_version is not None:
|
||||||
|
row.agent_min_ssh_version = min_ssh_version.strip() or None
|
||||||
|
if win_agent_update_script is not None:
|
||||||
|
row.win_agent_update_script = win_agent_update_script.strip() or None
|
||||||
|
if rdp_git_repo_url is not None:
|
||||||
|
row.agent_rdp_git_repo_url = rdp_git_repo_url.strip() or None
|
||||||
|
row.agent_git_rdp_version = None
|
||||||
|
row.agent_git_fetched_at = None
|
||||||
|
if ssh_git_repo_url is not None:
|
||||||
|
row.agent_ssh_git_repo_url = ssh_git_repo_url.strip() or None
|
||||||
|
row.agent_git_ssh_version = None
|
||||||
|
row.agent_git_fetched_at = None
|
||||||
|
if git_branch is not None:
|
||||||
|
branch = git_branch.strip() or "main"
|
||||||
|
if branch != (row.agent_git_branch or "main"):
|
||||||
|
row.agent_git_rdp_version = None
|
||||||
|
row.agent_git_ssh_version = None
|
||||||
|
row.agent_git_fetched_at = None
|
||||||
|
row.agent_git_branch = branch
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return get_effective_agent_update_config(db)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Agent update fallback result with remote command streams."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentUpdateFallbackResult:
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
product_version: str | None
|
||||||
|
target: str = ""
|
||||||
|
stdout: str = ""
|
||||||
|
stderr: str = ""
|
||||||
|
exit_code: int | None = None
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Сравнение версий агентов (ssh-monitor / rdp-login-monitor, формат X.Y.Z-SAC)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Host
|
||||||
|
|
||||||
|
_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-SAC)?$", re.IGNORECASE)
|
||||||
|
_DEFAULT_LAG_THRESHOLD = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, order=True)
|
||||||
|
class ParsedAgentVersion:
|
||||||
|
major: int
|
||||||
|
minor: int
|
||||||
|
patch: int
|
||||||
|
|
||||||
|
|
||||||
|
def parse_agent_version(raw: str | None) -> ParsedAgentVersion | None:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
text = raw.strip()
|
||||||
|
match = _VERSION_RE.match(text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return ParsedAgentVersion(
|
||||||
|
major=int(match.group(1)),
|
||||||
|
minor=int(match.group(2)),
|
||||||
|
patch=int(match.group(3)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_version_lag(host: ParsedAgentVersion, latest: ParsedAgentVersion) -> int | None:
|
||||||
|
"""Отставание от latest: patch при том же major.minor, иначе None (любое отставание)."""
|
||||||
|
if host > latest:
|
||||||
|
return 0
|
||||||
|
if host.major != latest.major or host.minor != latest.minor:
|
||||||
|
return None
|
||||||
|
return latest.patch - host.patch
|
||||||
|
|
||||||
|
|
||||||
|
def is_agent_version_outdated(
|
||||||
|
host_version: str | None,
|
||||||
|
latest_version: str | None,
|
||||||
|
*,
|
||||||
|
lag_threshold: int = _DEFAULT_LAG_THRESHOLD,
|
||||||
|
) -> bool:
|
||||||
|
host = parse_agent_version(host_version)
|
||||||
|
latest = parse_agent_version(latest_version)
|
||||||
|
if host is None or latest is None or host >= latest:
|
||||||
|
return False
|
||||||
|
lag = agent_version_lag(host, latest)
|
||||||
|
if lag is None:
|
||||||
|
return True
|
||||||
|
return lag >= lag_threshold
|
||||||
|
|
||||||
|
|
||||||
|
def effective_reference_version(
|
||||||
|
*,
|
||||||
|
recommended: str = "",
|
||||||
|
fleet_latest: str = "",
|
||||||
|
min_version: str = "",
|
||||||
|
) -> str | None:
|
||||||
|
"""Единый эталон: max(recommended, min_version, max версия в fleet по продукту)."""
|
||||||
|
best: ParsedAgentVersion | None = None
|
||||||
|
best_raw: str | None = None
|
||||||
|
for raw in (recommended, min_version, fleet_latest):
|
||||||
|
text = (raw or "").strip()
|
||||||
|
parsed = parse_agent_version(text)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
if best is None or parsed > best:
|
||||||
|
best = parsed
|
||||||
|
best_raw = text
|
||||||
|
return best_raw
|
||||||
|
|
||||||
|
|
||||||
|
def reference_agent_versions_by_product(
|
||||||
|
cfg,
|
||||||
|
latest_map: dict[str, str],
|
||||||
|
*,
|
||||||
|
git_versions: dict[str, str] | None = None,
|
||||||
|
products: tuple[str, ...] = ("rdp-login-monitor", "ssh-monitor"),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
from app.services.agent_git_release import recommended_from_git
|
||||||
|
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||||
|
|
||||||
|
git_versions = git_versions or {}
|
||||||
|
product_cfg = {
|
||||||
|
PRODUCT_RDP: (recommended_from_git(cfg, git_versions, PRODUCT_RDP), cfg.min_for_product(PRODUCT_RDP)),
|
||||||
|
PRODUCT_SSH: (recommended_from_git(cfg, git_versions, PRODUCT_SSH), cfg.min_for_product(PRODUCT_SSH)),
|
||||||
|
}
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for product in products:
|
||||||
|
recommended, min_version = product_cfg.get(product, ("", ""))
|
||||||
|
ref = effective_reference_version(
|
||||||
|
recommended=recommended,
|
||||||
|
fleet_latest=latest_map.get(product, ""),
|
||||||
|
min_version=min_version,
|
||||||
|
)
|
||||||
|
if ref:
|
||||||
|
out[product] = ref
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def host_version_outdated(
|
||||||
|
host_version: str | None,
|
||||||
|
*,
|
||||||
|
recommended: str = "",
|
||||||
|
fleet_latest: str = "",
|
||||||
|
min_version: str = "",
|
||||||
|
lag_threshold: int = _DEFAULT_LAG_THRESHOLD,
|
||||||
|
) -> bool | None:
|
||||||
|
"""True=outdated, False=ok, None=unknown (нет эталона или непарсится версия хоста)."""
|
||||||
|
if not host_version or parse_agent_version(host_version) is None:
|
||||||
|
return None
|
||||||
|
reference = effective_reference_version(
|
||||||
|
recommended=recommended,
|
||||||
|
fleet_latest=fleet_latest,
|
||||||
|
min_version=min_version,
|
||||||
|
)
|
||||||
|
if not reference:
|
||||||
|
return None
|
||||||
|
return is_agent_version_outdated(host_version, reference, lag_threshold=lag_threshold)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_agent_versions_by_product(db: Session) -> dict[str, str]:
|
||||||
|
rows = db.execute(
|
||||||
|
select(Host.product, Host.product_version).where(
|
||||||
|
Host.product_version.isnot(None),
|
||||||
|
Host.product_version != "",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
best: dict[str, ParsedAgentVersion] = {}
|
||||||
|
best_raw: dict[str, str] = {}
|
||||||
|
for product, version in rows:
|
||||||
|
parsed = parse_agent_version(version)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
current = best.get(product)
|
||||||
|
if current is None or parsed > current:
|
||||||
|
best[product] = parsed
|
||||||
|
best_raw[product] = version.strip()
|
||||||
|
return best_raw
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Resolve client IP behind reverse proxy (nginx $proxy_add_x_forwarded_for)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip_from_request(request: Request) -> str:
|
||||||
|
"""Client IP for rate limiting.
|
||||||
|
|
||||||
|
nginx with ``proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for``
|
||||||
|
appends the real peer address as the *last* hop. Spoofed values in the
|
||||||
|
incoming header therefore cannot replace the actual client IP.
|
||||||
|
"""
|
||||||
|
if request.client and request.client.host:
|
||||||
|
direct = request.client.host.strip()
|
||||||
|
else:
|
||||||
|
direct = "unknown"
|
||||||
|
|
||||||
|
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
|
||||||
|
if not forwarded:
|
||||||
|
return direct[:64]
|
||||||
|
|
||||||
|
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||||
|
if not parts:
|
||||||
|
return direct[:64]
|
||||||
|
return parts[-1][:64]
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""SAC-generated daily reports from ingested events (F-NOT-05 / notif-32)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import Settings, get_settings
|
||||||
|
from app.models import Event, Host
|
||||||
|
from app.services.daily_report_format import (
|
||||||
|
RDP_BAN_TYPES,
|
||||||
|
SSH_BAN_TYPES,
|
||||||
|
body_to_report_html,
|
||||||
|
build_report_body,
|
||||||
|
enrich_stats_for_storage,
|
||||||
|
normalize_daily_report_details,
|
||||||
|
)
|
||||||
|
from app.services.ingest import ingest_event
|
||||||
|
from app.services.notify_dispatch import notify_daily_report
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REPORT_TYPES = {
|
||||||
|
"ssh-monitor": "report.daily.ssh",
|
||||||
|
"rdp-login-monitor": "report.daily.rdp",
|
||||||
|
}
|
||||||
|
|
||||||
|
SSH_SUCCESS = "ssh.login.success"
|
||||||
|
SSH_FAILED = "ssh.login.failed"
|
||||||
|
SSH_SUDO = "privilege.sudo.command"
|
||||||
|
RDP_SUCCESS = "rdp.login.success"
|
||||||
|
RDP_FAILED = "rdp.login.failed"
|
||||||
|
SESSION_LOGIND_NEW = "session.logind.new"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DailyReportResult:
|
||||||
|
host_id: int
|
||||||
|
hostname: str
|
||||||
|
report_type: str
|
||||||
|
event_id: str
|
||||||
|
created: bool
|
||||||
|
skipped_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _now_in_tz(settings: Settings) -> datetime:
|
||||||
|
tz = ZoneInfo(settings.sac_daily_report_timezone.strip() or "Europe/Moscow")
|
||||||
|
return datetime.now(timezone.utc).astimezone(tz)
|
||||||
|
|
||||||
|
|
||||||
|
def _report_type_for_host(host: Host) -> str | None:
|
||||||
|
return REPORT_TYPES.get((host.product or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _day_start_in_tz(settings: Settings, ref: datetime | None = None) -> datetime:
|
||||||
|
local = ref or _now_in_tz(settings)
|
||||||
|
start = local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
return start.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def host_has_report_today(db: Session, host_id: int, report_type: str, settings: Settings) -> bool:
|
||||||
|
since = _day_start_in_tz(settings)
|
||||||
|
row = db.scalar(
|
||||||
|
select(Event.id)
|
||||||
|
.where(
|
||||||
|
Event.host_id == host_id,
|
||||||
|
Event.type == report_type,
|
||||||
|
Event.received_at >= since,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_from_details(details: dict[str, Any] | None) -> str:
|
||||||
|
if not details:
|
||||||
|
return ""
|
||||||
|
for key in ("source_ip", "ip_address", "ip"):
|
||||||
|
val = details.get(key)
|
||||||
|
if val is not None and str(val).strip() not in ("", "-"):
|
||||||
|
return str(val).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _user_from_details(details: dict[str, Any] | None) -> str:
|
||||||
|
if not details:
|
||||||
|
return ""
|
||||||
|
for key in ("user", "username"):
|
||||||
|
val = details.get(key)
|
||||||
|
if val is not None and str(val).strip():
|
||||||
|
return str(val).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _format_user_line_ssh(details: dict[str, Any] | None) -> str | None:
|
||||||
|
user = _user_from_details(details)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not details:
|
||||||
|
return f"👤 {user}"
|
||||||
|
tty = str(details.get("tty") or details.get("pts") or "").strip()
|
||||||
|
since = str(details.get("since") or details.get("session_since") or "").strip()
|
||||||
|
ip = _ip_from_details(details)
|
||||||
|
parts = [f"👤 {user}"]
|
||||||
|
if tty:
|
||||||
|
parts.append(f"| {tty}")
|
||||||
|
if since:
|
||||||
|
parts.append(f"| с {since}")
|
||||||
|
if ip:
|
||||||
|
parts.append(f"| 🌐 {ip}")
|
||||||
|
return " ".join(parts) if len(parts) > 1 else f"👤 {user}"
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_active_users_ssh(events: list[Event]) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for ev in events:
|
||||||
|
if ev.type != SESSION_LOGIND_NEW:
|
||||||
|
continue
|
||||||
|
details = ev.details if isinstance(ev.details, dict) else None
|
||||||
|
line = _format_user_line_ssh(details)
|
||||||
|
if line and line not in seen:
|
||||||
|
lines.append(line)
|
||||||
|
seen.add(line)
|
||||||
|
if lines:
|
||||||
|
return lines
|
||||||
|
for ev in events:
|
||||||
|
if ev.type != SSH_SUCCESS:
|
||||||
|
continue
|
||||||
|
details = ev.details if isinstance(ev.details, dict) else None
|
||||||
|
user = _user_from_details(details)
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
key = user.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
ip = _ip_from_details(details)
|
||||||
|
line = f"👤 {user}"
|
||||||
|
if ip:
|
||||||
|
line += f" | 🌐 {ip}"
|
||||||
|
lines.append(line)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_active_users_rdp(events: list[Event]) -> list[str]:
|
||||||
|
users: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for ev in events:
|
||||||
|
if ev.type != RDP_SUCCESS:
|
||||||
|
continue
|
||||||
|
details = ev.details if isinstance(ev.details, dict) else None
|
||||||
|
user = _user_from_details(details)
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
key = user.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
users.append(f"👤 {user}")
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_ssh(events: list[Event]) -> dict[str, Any]:
|
||||||
|
ok = failed = sudo = 0
|
||||||
|
failed_ips: Counter[str] = Counter()
|
||||||
|
for ev in events:
|
||||||
|
if ev.type == SSH_SUCCESS:
|
||||||
|
ok += 1
|
||||||
|
elif ev.type == SSH_FAILED:
|
||||||
|
failed += 1
|
||||||
|
ip = _ip_from_details(ev.details if isinstance(ev.details, dict) else None)
|
||||||
|
if ip:
|
||||||
|
failed_ips[ip] += 1
|
||||||
|
elif ev.type == SSH_SUDO:
|
||||||
|
sudo += 1
|
||||||
|
top_ips = [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)]
|
||||||
|
active_users = _collect_active_users_ssh(events)
|
||||||
|
return {
|
||||||
|
"successful_ssh": ok,
|
||||||
|
"failed_ssh": failed,
|
||||||
|
"sudo_commands": sudo,
|
||||||
|
"active_bans": sum(1 for e in events if e.type in SSH_BAN_TYPES),
|
||||||
|
"top_failed_ips": top_ips,
|
||||||
|
"active_users": active_users,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_rdp(events: list[Event]) -> dict[str, Any]:
|
||||||
|
ok = failed = 0
|
||||||
|
failed_ips: Counter[str] = Counter()
|
||||||
|
for ev in events:
|
||||||
|
details = ev.details if isinstance(ev.details, dict) else None
|
||||||
|
if ev.type == RDP_SUCCESS:
|
||||||
|
ok += 1
|
||||||
|
elif ev.type == RDP_FAILED:
|
||||||
|
failed += 1
|
||||||
|
ip = _ip_from_details(details)
|
||||||
|
if ip:
|
||||||
|
failed_ips[ip] += 1
|
||||||
|
active_users = _collect_active_users_rdp(events)
|
||||||
|
unique = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for line in active_users:
|
||||||
|
u = line.replace("👤", "").strip().split("|")[0].strip()
|
||||||
|
if u.lower() not in seen:
|
||||||
|
seen.add(u.lower())
|
||||||
|
unique.append(u)
|
||||||
|
return {
|
||||||
|
"rdp_success": ok,
|
||||||
|
"rdp_failed": failed,
|
||||||
|
"active_bans": sum(1 for e in events if e.type in RDP_BAN_TYPES),
|
||||||
|
"top_failed_ips": [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)],
|
||||||
|
"active_users": active_users,
|
||||||
|
"unique_users": unique,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_report_body_ssh(host: Host, stats: dict[str, Any], when_local: datetime) -> str:
|
||||||
|
enriched = enrich_stats_for_storage("ssh", stats)
|
||||||
|
return build_report_body("ssh", host, enriched, when_local, sac_generated=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_report_body_rdp(host: Host, stats: dict[str, Any], when_local: datetime) -> str:
|
||||||
|
enriched = enrich_stats_for_storage("windows", stats)
|
||||||
|
return build_report_body("windows", host, enriched, when_local, sac_generated=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _details_from_body(body: str, stats: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"stats": stats,
|
||||||
|
"report_body": body,
|
||||||
|
"report_format": "plain",
|
||||||
|
"report_html": body_to_report_html(body),
|
||||||
|
"generated_by": "sac",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _events_last_24h(db: Session, host_id: int) -> list[Event]:
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(Event)
|
||||||
|
.where(Event.host_id == host_id, Event.occurred_at >= since)
|
||||||
|
.order_by(Event.occurred_at.desc())
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_daily_report_for_host(db: Session, host: Host, settings: Settings | None = None) -> DailyReportResult | None:
|
||||||
|
cfg = settings or get_settings()
|
||||||
|
report_type = _report_type_for_host(host)
|
||||||
|
if report_type is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if cfg.sac_daily_report_skip_if_agent_sent and host_has_report_today(db, host.id, report_type, cfg):
|
||||||
|
return DailyReportResult(
|
||||||
|
host_id=host.id,
|
||||||
|
hostname=host.hostname,
|
||||||
|
report_type=report_type,
|
||||||
|
event_id="",
|
||||||
|
created=False,
|
||||||
|
skipped_reason="agent_report_exists_today",
|
||||||
|
)
|
||||||
|
|
||||||
|
events = _events_last_24h(db, host.id)
|
||||||
|
if not events and cfg.sac_daily_report_require_activity:
|
||||||
|
return DailyReportResult(
|
||||||
|
host_id=host.id,
|
||||||
|
hostname=host.hostname,
|
||||||
|
report_type=report_type,
|
||||||
|
event_id="",
|
||||||
|
created=False,
|
||||||
|
skipped_reason="no_events_24h",
|
||||||
|
)
|
||||||
|
|
||||||
|
when_local = _now_in_tz(cfg)
|
||||||
|
if report_type == "report.daily.ssh":
|
||||||
|
stats = enrich_stats_for_storage("ssh", _aggregate_ssh(events))
|
||||||
|
body = _build_report_body_ssh(host, stats, when_local)
|
||||||
|
title = "Ежедневный отчёт SSH"
|
||||||
|
summary = (
|
||||||
|
f"SSH 24ч: успех {stats['successful_logins']}, неудач {stats['failed_logins']}, "
|
||||||
|
f"sudo {stats['sudo_commands']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stats = enrich_stats_for_storage("windows", _aggregate_rdp(events))
|
||||||
|
body = _build_report_body_rdp(host, stats, when_local)
|
||||||
|
title = "Ежедневный отчёт Windows"
|
||||||
|
summary = (
|
||||||
|
f"RDP 24ч: успех {stats['successful_logins']}, неудач {stats['failed_logins']}, "
|
||||||
|
f"банов {stats['active_bans']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
event_id = str(uuid.uuid4())
|
||||||
|
day_key = when_local.strftime("%Y-%m-%d")
|
||||||
|
payload = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"event_id": event_id,
|
||||||
|
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"source": {
|
||||||
|
"product": host.product or "unknown",
|
||||||
|
"product_version": host.product_version or "sac",
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"hostname": host.hostname,
|
||||||
|
"os_family": host.os_family or ("windows" if report_type == "report.daily.rdp" else "linux"),
|
||||||
|
"display_name": host.display_name,
|
||||||
|
"ipv4": host.ipv4,
|
||||||
|
},
|
||||||
|
"category": "report",
|
||||||
|
"type": report_type,
|
||||||
|
"severity": "info",
|
||||||
|
"title": title,
|
||||||
|
"summary": summary,
|
||||||
|
"details": _details_from_body(body, stats),
|
||||||
|
"dedup_key": f"sac|{host.id}|{report_type}|{day_key}",
|
||||||
|
}
|
||||||
|
|
||||||
|
event, created = ingest_event(db, payload)
|
||||||
|
if created:
|
||||||
|
notify_daily_report(event, db=db)
|
||||||
|
return DailyReportResult(
|
||||||
|
host_id=host.id,
|
||||||
|
hostname=host.hostname,
|
||||||
|
report_type=report_type,
|
||||||
|
event_id=event.event_id,
|
||||||
|
created=created,
|
||||||
|
skipped_reason=None if created else "duplicate_event_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_daily_reports(db: Session, settings: Settings | None = None, *, force: bool = False) -> list[DailyReportResult]:
|
||||||
|
cfg = settings or get_settings()
|
||||||
|
if not cfg.sac_daily_report_enabled:
|
||||||
|
logger.info("daily report disabled (SAC_DAILY_REPORT_ENABLED=false)")
|
||||||
|
return []
|
||||||
|
|
||||||
|
local_now = _now_in_tz(cfg)
|
||||||
|
if not force and local_now.hour < cfg.sac_daily_report_hour:
|
||||||
|
logger.info(
|
||||||
|
"daily report skipped: local hour %s < SAC_DAILY_REPORT_HOUR=%s",
|
||||||
|
local_now.hour,
|
||||||
|
cfg.sac_daily_report_hour,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
hosts = db.scalars(select(Host).order_by(Host.hostname)).all()
|
||||||
|
results: list[DailyReportResult] = []
|
||||||
|
for host in hosts:
|
||||||
|
try:
|
||||||
|
res = generate_daily_report_for_host(db, host, cfg)
|
||||||
|
if res is not None:
|
||||||
|
results.append(res)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("daily report failed host_id=%s hostname=%s", host.id, host.hostname)
|
||||||
|
db.commit()
|
||||||
|
created_n = sum(1 for r in results if r.created)
|
||||||
|
logger.info("daily report finished hosts=%s created=%s", len(results), created_n)
|
||||||
|
return results
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
"""Единый текст и stats для report.daily.ssh / report.daily.rdp (агент и SAC)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from app.models import Host
|
||||||
|
|
||||||
|
Platform = Literal["ssh", "windows"]
|
||||||
|
|
||||||
|
RDP_BAN_TYPES = frozenset({"rdp.ip.banned", "rdp.ip.ban"})
|
||||||
|
SSH_BAN_TYPES = frozenset({"ssh.ip.banned"})
|
||||||
|
|
||||||
|
_SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\s*:")
|
||||||
|
_ACTIVE_USERS_HEADER_RE = re.compile(r"^👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ")
|
||||||
|
_ACTIVE_USERS_EMPTY_LINE_RE = re.compile(
|
||||||
|
r"^\(?нет данных|нет активных пользователей",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_AGENT_VERSION_LINE_RE = re.compile(r"(?m)^Agent version\s+", re.IGNORECASE)
|
||||||
|
_NOTIFICATION_SOURCE_RE = re.compile(r"(?m)^📡\s*Оповещение:\s*")
|
||||||
|
_LEGACY_SAC_SOURCE_RE = re.compile(
|
||||||
|
r"(?m)^Источник:\s*Security Alert Center\b.*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_DAILY_REPORT_TITLE_MARKER = "ЕЖЕДНЕВНЫЙ ОТЧЕТ"
|
||||||
|
_SECTION_HEADER_RE = re.compile(r"^[📈🧾👥🖥️🕐]")
|
||||||
|
NOTIFICATION_SOURCE_PREFIX = "📡 Оповещение:"
|
||||||
|
|
||||||
|
|
||||||
|
def format_notification_source_plain(
|
||||||
|
*,
|
||||||
|
generated_by: str,
|
||||||
|
product: str | None = None,
|
||||||
|
product_version: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
gb = (generated_by or "agent").strip().lower()
|
||||||
|
if gb == "sac":
|
||||||
|
return f"{NOTIFICATION_SOURCE_PREFIX} SAC (Security Alert Center)"
|
||||||
|
label = (product or "агент").strip()
|
||||||
|
version = (product_version or "").strip()
|
||||||
|
if version:
|
||||||
|
return f"{NOTIFICATION_SOURCE_PREFIX} агент ({label} {version})"
|
||||||
|
return f"{NOTIFICATION_SOURCE_PREFIX} агент ({label})"
|
||||||
|
|
||||||
|
|
||||||
|
def has_notification_source_line(text: str) -> bool:
|
||||||
|
return bool(_NOTIFICATION_SOURCE_RE.search(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def strip_legacy_sac_source_line(text: str) -> str:
|
||||||
|
return _LEGACY_SAC_SOURCE_RE.sub("", text or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def append_notification_source_plain(
|
||||||
|
body: str,
|
||||||
|
*,
|
||||||
|
generated_by: str,
|
||||||
|
product: str | None = None,
|
||||||
|
product_version: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
text = strip_legacy_sac_source_line(body or "")
|
||||||
|
if has_notification_source_line(text):
|
||||||
|
return text
|
||||||
|
line = format_notification_source_plain(
|
||||||
|
generated_by=generated_by,
|
||||||
|
product=product,
|
||||||
|
product_version=product_version,
|
||||||
|
)
|
||||||
|
if text:
|
||||||
|
return f"{text.rstrip()}\n\n{line}"
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def append_notification_source_html(
|
||||||
|
html_msg: str,
|
||||||
|
*,
|
||||||
|
generated_by: str,
|
||||||
|
product: str | None = None,
|
||||||
|
product_version: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
plain_line = format_notification_source_plain(
|
||||||
|
generated_by=generated_by,
|
||||||
|
product=product,
|
||||||
|
product_version=product_version,
|
||||||
|
)
|
||||||
|
if NOTIFICATION_SOURCE_PREFIX in (html_msg or ""):
|
||||||
|
return (html_msg or "").rstrip()
|
||||||
|
line = html.escape(plain_line)
|
||||||
|
msg = (html_msg or "").rstrip()
|
||||||
|
if msg:
|
||||||
|
return f"{msg}\n{line}"
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_product_label(host: Host | None, platform: Platform | None = None) -> tuple[str | None, str | None]:
|
||||||
|
if host is None:
|
||||||
|
return None, None
|
||||||
|
product = (host.product or "").strip()
|
||||||
|
if not product:
|
||||||
|
if platform == "ssh":
|
||||||
|
product = "ssh-monitor"
|
||||||
|
elif platform == "windows":
|
||||||
|
product = "rdp-login-monitor"
|
||||||
|
version = (host.product_version or "").strip() or None
|
||||||
|
return (product or None), version
|
||||||
|
|
||||||
|
|
||||||
|
def host_server_line(host: Host | None) -> str:
|
||||||
|
if host is None:
|
||||||
|
return "unknown"
|
||||||
|
label = (host.display_name or host.hostname or "unknown").strip()
|
||||||
|
ip = (host.ipv4 or "").strip()
|
||||||
|
if ip:
|
||||||
|
return f"{label} ({ip})"
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
def collapse_blank_lines(text: str, *, max_run: int = 1) -> str:
|
||||||
|
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||||
|
out: list[str] = []
|
||||||
|
blank_run = 0
|
||||||
|
for line in lines:
|
||||||
|
if not line.strip():
|
||||||
|
blank_run += 1
|
||||||
|
if blank_run <= max_run:
|
||||||
|
out.append("")
|
||||||
|
continue
|
||||||
|
blank_run = 0
|
||||||
|
out.append(line.rstrip())
|
||||||
|
return "\n".join(out).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_active_users_empty_line(text: str) -> bool:
|
||||||
|
"""Строка-заглушка вместо списка сессий (агент или SAC)."""
|
||||||
|
s = text.strip()
|
||||||
|
if not s:
|
||||||
|
return True
|
||||||
|
inner = s.lstrip("👤").strip()
|
||||||
|
if _ACTIVE_USERS_EMPTY_LINE_RE.search(inner):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def split_active_user_tokens(entry: str) -> list[str]:
|
||||||
|
"""Разбивает «👤 u1 👤 u2» на отдельные строки (как в отчёте агента)."""
|
||||||
|
entry = entry.strip()
|
||||||
|
if not entry or is_active_users_empty_line(entry):
|
||||||
|
return []
|
||||||
|
if entry.count("👤") <= 1:
|
||||||
|
line = entry if entry.startswith("👤") else f"👤 {entry}"
|
||||||
|
return [f" {line}"]
|
||||||
|
parts = re.split(r"(?=👤)", entry)
|
||||||
|
result: list[str] = []
|
||||||
|
for part in parts:
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if not part.startswith("👤"):
|
||||||
|
part = f"👤 {part}"
|
||||||
|
result.append(f" {part}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_active_users_list(users: list[Any] | None) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in users or []:
|
||||||
|
for line in split_active_user_tokens(str(raw)):
|
||||||
|
key = line.strip().lower()
|
||||||
|
if key and key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
out.append(line)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def format_agent_version_line(version: str) -> str:
|
||||||
|
v = (version or "").strip()
|
||||||
|
if not v:
|
||||||
|
return ""
|
||||||
|
return f"Agent version {v}"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_agent_version_line(body: str, version: str | None) -> str:
|
||||||
|
line = format_agent_version_line(version or "")
|
||||||
|
if not line:
|
||||||
|
return body
|
||||||
|
if _AGENT_VERSION_LINE_RE.search(body):
|
||||||
|
return body
|
||||||
|
lines = body.replace("\r\n", "\n").split("\n")
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
if _DAILY_REPORT_TITLE_MARKER in ln:
|
||||||
|
lines.insert(i + 1, line)
|
||||||
|
return "\n".join(lines)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_active_users_header_count(header_line: str, user_count: int) -> str:
|
||||||
|
stripped = header_line.rstrip()
|
||||||
|
if _ACTIVE_USERS_HEADER_RE.match(stripped.strip()):
|
||||||
|
return re.sub(
|
||||||
|
r"(\👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ\s*\()[^)]+(\))",
|
||||||
|
rf"\g<1>{user_count}\2",
|
||||||
|
stripped,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_server_line(body: str, host: Host | None) -> str:
|
||||||
|
server = host_server_line(host)
|
||||||
|
if not server or server == "unknown":
|
||||||
|
return body
|
||||||
|
if _SERVER_LINE_RE.search(body):
|
||||||
|
return body
|
||||||
|
lines = body.replace("\r\n", "\n").split("\n")
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if "ЕЖЕДНЕВНЫЙ ОТЧЕТ" in line:
|
||||||
|
insert_at = i + 1
|
||||||
|
if insert_at < len(lines) and _AGENT_VERSION_LINE_RE.match(lines[insert_at].strip()):
|
||||||
|
insert_at += 1
|
||||||
|
lines.insert(insert_at, f"🖥️ Сервер: {server}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
return f"🖥️ Сервер: {server}\n{body}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_active_users_in_body(body: str) -> str:
|
||||||
|
lines = body.replace("\r\n", "\n").split("\n")
|
||||||
|
out: list[str] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
if _ACTIVE_USERS_HEADER_RE.match(line.strip()):
|
||||||
|
out.append(line)
|
||||||
|
i += 1
|
||||||
|
user_lines: list[str] = []
|
||||||
|
while i < len(lines):
|
||||||
|
cur = lines[i]
|
||||||
|
stripped = cur.strip()
|
||||||
|
if not stripped:
|
||||||
|
break
|
||||||
|
if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX):
|
||||||
|
break
|
||||||
|
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
|
||||||
|
break
|
||||||
|
if is_active_users_empty_line(stripped):
|
||||||
|
out[-1] = _fix_active_users_header_count(out[-1], 0)
|
||||||
|
out.append(cur)
|
||||||
|
i += 1
|
||||||
|
break
|
||||||
|
user_lines.extend(split_active_user_tokens(cur))
|
||||||
|
i += 1
|
||||||
|
user_lines = [ln for ln in user_lines if not is_active_users_empty_line(ln.strip())]
|
||||||
|
if user_lines:
|
||||||
|
out[-1] = _fix_active_users_header_count(out[-1], len(user_lines))
|
||||||
|
out.extend(user_lines)
|
||||||
|
else:
|
||||||
|
out[-1] = _fix_active_users_header_count(out[-1], 0)
|
||||||
|
continue
|
||||||
|
out.append(line)
|
||||||
|
i += 1
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_active_users_from_report_body(body: str) -> list[str]:
|
||||||
|
lines = body.replace("\r\n", "\n").split("\n")
|
||||||
|
in_section = False
|
||||||
|
users: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if _ACTIVE_USERS_HEADER_RE.match(stripped):
|
||||||
|
in_section = True
|
||||||
|
continue
|
||||||
|
if not in_section:
|
||||||
|
continue
|
||||||
|
if not stripped:
|
||||||
|
break
|
||||||
|
if is_active_users_empty_line(stripped):
|
||||||
|
break
|
||||||
|
if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX):
|
||||||
|
break
|
||||||
|
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
|
||||||
|
break
|
||||||
|
users.extend(split_active_user_tokens(stripped))
|
||||||
|
return normalize_active_users_list(users)
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_stats_from_report_body(
|
||||||
|
stats: dict[str, Any],
|
||||||
|
body: str,
|
||||||
|
platform: Platform,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Синхронизирует stats.active_users и счётчики с текстом отчёта после нормализации."""
|
||||||
|
out = dict(stats)
|
||||||
|
users = extract_active_users_from_report_body(body)
|
||||||
|
out["active_users"] = users
|
||||||
|
count = len(users)
|
||||||
|
if platform == "ssh":
|
||||||
|
out["active_sessions"] = count
|
||||||
|
else:
|
||||||
|
out["active_sessions_rdp"] = count
|
||||||
|
names: list[str] = []
|
||||||
|
for raw in users:
|
||||||
|
name = re.sub(r"^👤\s*", "", raw.strip()).strip()
|
||||||
|
if name and not is_active_users_empty_line(name):
|
||||||
|
names.append(name)
|
||||||
|
out["unique_users"] = sorted(set(names))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_report_body(body: str, host: Host | None, platform: Platform) -> str:
|
||||||
|
"""Приводит текст отчёта (агент/SAC) к единому компактному виду."""
|
||||||
|
text = collapse_blank_lines(body.replace("\r\n", "\n"))
|
||||||
|
agent_version = host.product_version if host else None
|
||||||
|
text = ensure_agent_version_line(text, agent_version)
|
||||||
|
text = ensure_server_line(text, host)
|
||||||
|
text = normalize_active_users_in_body(text)
|
||||||
|
return collapse_blank_lines(text)
|
||||||
|
|
||||||
|
|
||||||
|
def body_to_report_html(body: str) -> str:
|
||||||
|
escaped = html.escape(body)
|
||||||
|
return f'<div class="agent-report">{escaped.replace(chr(10), "<br>")}</div>'
|
||||||
|
|
||||||
|
|
||||||
|
def _section_top_ips(top: list[str]) -> list[str]:
|
||||||
|
lines = [" 🧾 ТОП-5 IP ПО НЕУДАЧНЫМ ПОПЫТКАМ:"]
|
||||||
|
if top:
|
||||||
|
lines.extend(f" {line}" if not line.startswith(" ") else line for line in top[:5])
|
||||||
|
else:
|
||||||
|
lines.append(" (нет данных)")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _section_active_users(users: list[str], *, sac_generated: bool) -> list[str]:
|
||||||
|
count = len(users)
|
||||||
|
lines = [f" 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ ({count}):"]
|
||||||
|
if users:
|
||||||
|
for line in users:
|
||||||
|
lines.append(f" {line}" if not line.startswith(" ") else line)
|
||||||
|
elif sac_generated:
|
||||||
|
lines.append(
|
||||||
|
" (нет данных — полный список сессий только в отчёте агента; "
|
||||||
|
"в SAC — пользователи из событий входа за 24 ч, если были)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append(" (нет данных)")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def build_report_body(
|
||||||
|
platform: Platform,
|
||||||
|
host: Host,
|
||||||
|
stats: dict[str, Any],
|
||||||
|
when_local: datetime,
|
||||||
|
*,
|
||||||
|
sac_generated: bool = False,
|
||||||
|
) -> str:
|
||||||
|
server = host_server_line(host)
|
||||||
|
time_str = when_local.strftime("%d.%m.%Y %H:%M:%S")
|
||||||
|
top = list(stats.get("top_failed_ips") or [])[:5]
|
||||||
|
active_users = normalize_active_users_list(list(stats.get("active_users") or []))
|
||||||
|
agent_version = (
|
||||||
|
str(stats.get("agent_version") or stats.get("product_version") or host.product_version or "")
|
||||||
|
).strip()
|
||||||
|
if sac_generated and not agent_version:
|
||||||
|
agent_version = "sac"
|
||||||
|
|
||||||
|
if platform == "ssh":
|
||||||
|
title = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА"
|
||||||
|
ok = int(stats.get("successful_logins", stats.get("successful_ssh", 0)))
|
||||||
|
fail = int(stats.get("failed_logins", stats.get("failed_ssh", 0)))
|
||||||
|
sudo = int(stats.get("sudo_commands", 0))
|
||||||
|
bans = int(stats.get("active_bans", 0))
|
||||||
|
lines = [title]
|
||||||
|
if agent_version:
|
||||||
|
lines.append(format_agent_version_line(agent_version))
|
||||||
|
lines.extend([
|
||||||
|
f"🖥️ Сервер: {server}",
|
||||||
|
f"🕐 Время отчета: {time_str}",
|
||||||
|
"",
|
||||||
|
" 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:",
|
||||||
|
f" ✅ Успешных SSH подключений: {ok}",
|
||||||
|
f" ❌ Неудачных попыток SSH: {fail}",
|
||||||
|
f" ⚠️ Команд через sudo: {sudo}",
|
||||||
|
f" 🚫 Активных банов (ipset): {bans}",
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
title = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS"
|
||||||
|
ok = int(stats.get("successful_logins", stats.get("rdp_success", 0)))
|
||||||
|
fail = int(stats.get("failed_logins", stats.get("rdp_failed", 0)))
|
||||||
|
bans = int(stats.get("active_bans", 0))
|
||||||
|
lines = [title]
|
||||||
|
if agent_version:
|
||||||
|
lines.append(format_agent_version_line(agent_version))
|
||||||
|
lines.extend([
|
||||||
|
f"🖥️ Сервер: {server}",
|
||||||
|
f"🕐 Время отчета: {time_str}",
|
||||||
|
"",
|
||||||
|
" 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:",
|
||||||
|
f" ✅ Успешных RDP подключений: {ok}",
|
||||||
|
f" ❌ Неудачных попыток RDP: {fail}",
|
||||||
|
f" 🚫 Активных банов: {bans}",
|
||||||
|
])
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(_section_top_ips(top))
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(_section_active_users(active_users, sac_generated=sac_generated))
|
||||||
|
|
||||||
|
generated_by = "sac" if sac_generated else "agent"
|
||||||
|
product, version = resolve_product_label(host, platform)
|
||||||
|
if not sac_generated and agent_version:
|
||||||
|
version = agent_version
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
format_notification_source_plain(
|
||||||
|
generated_by=generated_by,
|
||||||
|
product=product,
|
||||||
|
product_version=version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_stats_for_storage(platform: Platform, stats: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Канонические поля + legacy-ключи для UI и старых отчётов."""
|
||||||
|
out = dict(stats)
|
||||||
|
out["platform"] = platform
|
||||||
|
if platform == "ssh":
|
||||||
|
ok = int(out.get("successful_logins", out.get("successful_ssh", 0)))
|
||||||
|
fail = int(out.get("failed_logins", out.get("failed_ssh", 0)))
|
||||||
|
out["successful_logins"] = ok
|
||||||
|
out["failed_logins"] = fail
|
||||||
|
out["successful_ssh"] = ok
|
||||||
|
out["failed_ssh"] = fail
|
||||||
|
else:
|
||||||
|
ok = int(out.get("successful_logins", out.get("rdp_success", 0)))
|
||||||
|
fail = int(out.get("failed_logins", out.get("rdp_failed", 0)))
|
||||||
|
out["successful_logins"] = ok
|
||||||
|
out["failed_logins"] = fail
|
||||||
|
out["rdp_success"] = ok
|
||||||
|
out["rdp_failed"] = fail
|
||||||
|
if "unique_users" in out and "active_users" not in out:
|
||||||
|
users = out.get("unique_users") or []
|
||||||
|
out["active_users"] = [f"👤 {u}" if not str(u).startswith("👤") else str(u) for u in users]
|
||||||
|
if "active_users" in out:
|
||||||
|
out["active_users"] = normalize_active_users_list(list(out.get("active_users") or []))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_daily_report_details(
|
||||||
|
details: dict[str, Any] | None,
|
||||||
|
host: Host | None,
|
||||||
|
report_type: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
return details
|
||||||
|
platform: Platform = "windows" if report_type == "report.daily.rdp" else "ssh"
|
||||||
|
body = details.get("report_body")
|
||||||
|
if not isinstance(body, str) or not body.strip():
|
||||||
|
return details
|
||||||
|
|
||||||
|
normalized_body = normalize_report_body(body, host, platform)
|
||||||
|
gb = str(details.get("generated_by") or "agent").strip().lower()
|
||||||
|
if gb not in ("agent", "sac"):
|
||||||
|
gb = "agent"
|
||||||
|
product, version = resolve_product_label(host, platform)
|
||||||
|
stats_raw = details.get("stats")
|
||||||
|
if isinstance(stats_raw, dict) and gb == "agent":
|
||||||
|
av = stats_raw.get("agent_version") or stats_raw.get("product_version")
|
||||||
|
if av:
|
||||||
|
version = str(av).strip()
|
||||||
|
normalized_body = append_notification_source_plain(
|
||||||
|
normalized_body,
|
||||||
|
generated_by=gb,
|
||||||
|
product=product,
|
||||||
|
product_version=version,
|
||||||
|
)
|
||||||
|
out = dict(details)
|
||||||
|
out["report_body"] = normalized_body
|
||||||
|
out["report_html"] = body_to_report_html(normalized_body)
|
||||||
|
|
||||||
|
stats = out.get("stats")
|
||||||
|
if isinstance(stats, dict):
|
||||||
|
stats = enrich_stats_for_storage(platform, dict(stats))
|
||||||
|
stats = reconcile_stats_from_report_body(stats, normalized_body, platform)
|
||||||
|
out["stats"] = stats
|
||||||
|
return out
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""SMTP email notifications from SAC."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Event, Problem
|
||||||
|
from app.services.notification_severity import severity_meets_minimum
|
||||||
|
from app.services.notification_settings import EmailConfig, get_effective_email_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailNotConfiguredError(Exception):
|
||||||
|
"""Email disabled or missing SMTP settings."""
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSendError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mail_recipients(mail_to: str) -> list[str]:
|
||||||
|
return [part.strip() for part in re.split(r"[,;]", mail_to) if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_message(
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
*,
|
||||||
|
config: EmailConfig | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> None:
|
||||||
|
cfg = config or get_effective_email_config()
|
||||||
|
if not force and not cfg.active:
|
||||||
|
return
|
||||||
|
if not cfg.configured:
|
||||||
|
if force:
|
||||||
|
raise EmailNotConfiguredError("SMTP: не задан host, from или to")
|
||||||
|
return
|
||||||
|
|
||||||
|
recipients = parse_mail_recipients(cfg.mail_to)
|
||||||
|
if not recipients:
|
||||||
|
if force:
|
||||||
|
raise EmailNotConfiguredError("SMTP: список получателей пуст")
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = MIMEText(body, "plain", "utf-8")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = cfg.mail_from
|
||||||
|
msg["To"] = ", ".join(recipients)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if cfg.smtp_ssl:
|
||||||
|
server: smtplib.SMTP = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=12)
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=12)
|
||||||
|
with server:
|
||||||
|
server.ehlo()
|
||||||
|
if cfg.smtp_starttls and not cfg.smtp_ssl:
|
||||||
|
server.starttls()
|
||||||
|
server.ehlo()
|
||||||
|
if cfg.smtp_user:
|
||||||
|
server.login(cfg.smtp_user, cfg.smtp_password)
|
||||||
|
server.sendmail(cfg.mail_from, recipients, msg.as_string())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("smtp send failed")
|
||||||
|
raise EmailSendError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_test_message(*, config: EmailConfig | None = None) -> None:
|
||||||
|
cfg = config or get_effective_email_config()
|
||||||
|
if not cfg.enabled:
|
||||||
|
raise EmailNotConfiguredError("Email отключён (enabled=false)")
|
||||||
|
if not cfg.configured:
|
||||||
|
raise EmailNotConfiguredError("Не задан SMTP host, from или to")
|
||||||
|
send_email_message(
|
||||||
|
"SAC: тестовое письмо",
|
||||||
|
"Тестовое сообщение Security Alert Center.\nКанал SMTP для оповещений работает.",
|
||||||
|
config=cfg,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
|
||||||
|
cfg = get_effective_email_config(db)
|
||||||
|
if not cfg.active:
|
||||||
|
return
|
||||||
|
if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity):
|
||||||
|
return
|
||||||
|
host = event.host.hostname if event.host else "unknown"
|
||||||
|
body = (
|
||||||
|
f"SAC событие\n"
|
||||||
|
f"Host: {host}\n"
|
||||||
|
f"Severity: {event.severity}\n"
|
||||||
|
f"Type: {event.type}\n"
|
||||||
|
f"Title: {event.title}\n"
|
||||||
|
f"Summary: {event.summary}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
send_email_message(f"SAC: {event.type} ({event.severity})", body, config=cfg)
|
||||||
|
except EmailSendError:
|
||||||
|
logger.exception("notify_event email failed event_id=%s", event.event_id)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
|
||||||
|
cfg = get_effective_email_config(db)
|
||||||
|
if not cfg.active:
|
||||||
|
return
|
||||||
|
if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity):
|
||||||
|
return
|
||||||
|
host = problem.host.hostname if problem.host else "unknown"
|
||||||
|
related = ""
|
||||||
|
if event is not None:
|
||||||
|
related = f"\nEvent: {event.type} ({event.severity})"
|
||||||
|
body = (
|
||||||
|
f"SAC Problem opened\n"
|
||||||
|
f"Host: {host}\n"
|
||||||
|
f"Severity: {problem.severity}\n"
|
||||||
|
f"Rule: {problem.rule_id or '-'}\n"
|
||||||
|
f"Title: {problem.title}\n"
|
||||||
|
f"Summary: {problem.summary}{related}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
send_email_message(f"SAC Problem: {problem.title}", body, config=cfg)
|
||||||
|
except EmailSendError:
|
||||||
|
logger.exception("notify_problem email failed problem_id=%s", problem.id)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Имя пользователя для событий подключения/аутентификации (колонка «Пользователь» в UI)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# События, где в details может быть учётная запись (SSH/RDP/sudo/logind и т.д.).
|
||||||
|
ACTOR_USER_EVENT_TYPES: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"ssh.login.success",
|
||||||
|
"ssh.login.failed",
|
||||||
|
"privilege.sudo.command",
|
||||||
|
"session.logind.new",
|
||||||
|
"session.logind.removed",
|
||||||
|
"session.logind.failed",
|
||||||
|
"rdp.login.success",
|
||||||
|
"rdp.login.failed",
|
||||||
|
"rdp.session.logoff",
|
||||||
|
"rdp.shadow.control.started",
|
||||||
|
"rdp.shadow.control.stopped",
|
||||||
|
"rdp.shadow.control.permission",
|
||||||
|
"winrm.session.started",
|
||||||
|
"smb.admin_share.access",
|
||||||
|
"auth.explicit.credentials",
|
||||||
|
"rdg.connection.success",
|
||||||
|
"rdg.connection.disconnected",
|
||||||
|
"rdg.connection.failed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_SHADOW_DETAIL_KEYS = ("shadower_user", "user", "username")
|
||||||
|
_DEFAULT_DETAIL_KEYS = ("user", "username")
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_detail_value(details: dict[str, Any], keys: tuple[str, ...]) -> str | None:
|
||||||
|
for key in keys:
|
||||||
|
val = details.get(key)
|
||||||
|
if val is not None and str(val).strip() not in ("", "-"):
|
||||||
|
return str(val).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_event_actor_user(event_type: str, details: dict[str, Any] | None) -> str | None:
|
||||||
|
"""Вернуть имя пользователя для auth/connect событий или None (UI покажет «—»)."""
|
||||||
|
if event_type not in ACTOR_USER_EVENT_TYPES:
|
||||||
|
return None
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
return None
|
||||||
|
keys = _SHADOW_DETAIL_KEYS if event_type.startswith("rdp.shadow.") else _DEFAULT_DETAIL_KEYS
|
||||||
|
return _pick_detail_value(details, keys)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Per event-type severity overrides (admin Settings UI)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.constants.event_types import DEFAULT_EVENT_SEVERITIES
|
||||||
|
from app.models.event_severity_override import EventSeverityOverride
|
||||||
|
from app.services.event_type_visibility import get_visibility_map
|
||||||
|
from app.services.notification_settings import VALID_SEVERITIES
|
||||||
|
|
||||||
|
SOURCE_DB = "db"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EventSeverityOverrideItem:
|
||||||
|
event_type: str
|
||||||
|
default_severity: str
|
||||||
|
override_severity: str | None
|
||||||
|
show_in_events: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_override_map(db: Session) -> dict[str, str]:
|
||||||
|
rows = db.scalars(select(EventSeverityOverride)).all()
|
||||||
|
return {row.event_type: row.severity for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_severity_override(payload: dict, db: Session) -> dict:
|
||||||
|
"""Return payload copy with severity replaced when override exists."""
|
||||||
|
event_type = str(payload.get("type") or "")
|
||||||
|
if not event_type:
|
||||||
|
return payload
|
||||||
|
override_map = get_override_map(db)
|
||||||
|
override = override_map.get(event_type)
|
||||||
|
if not override:
|
||||||
|
return payload
|
||||||
|
agent_severity = str(payload.get("severity") or "info")
|
||||||
|
if agent_severity == override:
|
||||||
|
return payload
|
||||||
|
out = dict(payload)
|
||||||
|
details = dict(out.get("details") or {})
|
||||||
|
details["severity_agent"] = agent_severity
|
||||||
|
out["details"] = details
|
||||||
|
out["severity"] = override
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]:
|
||||||
|
override_map = get_override_map(db)
|
||||||
|
visibility_map = get_visibility_map(db)
|
||||||
|
types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map) | set(visibility_map)
|
||||||
|
items: list[EventSeverityOverrideItem] = []
|
||||||
|
for event_type in sorted(types):
|
||||||
|
default = DEFAULT_EVENT_SEVERITIES.get(event_type, "info")
|
||||||
|
items.append(
|
||||||
|
EventSeverityOverrideItem(
|
||||||
|
event_type=event_type,
|
||||||
|
default_severity=default,
|
||||||
|
override_severity=override_map.get(event_type),
|
||||||
|
show_in_events=visibility_map.get(event_type, True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def replace_severity_overrides(db: Session, overrides: dict[str, str | None]) -> list[EventSeverityOverrideItem]:
|
||||||
|
for event_type, severity in overrides.items():
|
||||||
|
normalized_type = event_type.strip()
|
||||||
|
if not normalized_type:
|
||||||
|
raise ValueError("event_type is required")
|
||||||
|
if severity is None or severity == "":
|
||||||
|
db.execute(
|
||||||
|
delete(EventSeverityOverride).where(EventSeverityOverride.event_type == normalized_type)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if severity not in VALID_SEVERITIES:
|
||||||
|
raise ValueError(f"severity must be one of: {sorted(VALID_SEVERITIES)}")
|
||||||
|
row = db.get(EventSeverityOverride, normalized_type)
|
||||||
|
if row is None:
|
||||||
|
row = EventSeverityOverride(event_type=normalized_type, severity=severity)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.severity = severity
|
||||||
|
db.flush()
|
||||||
|
return list_severity_override_items(db)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.schemas.list_models import EventSummary
|
||||||
|
from app.services.event_actor_user import extract_event_actor_user
|
||||||
|
from app.services.host_sessions import event_session_terminated
|
||||||
|
from app.services.rdg_display import build_rdg_display
|
||||||
|
from app.services.rdg_session_flap import resolve_rdg_flap_summary
|
||||||
|
from app.services.session_duration import extract_session_duration_sec
|
||||||
|
|
||||||
|
|
||||||
|
def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
|
||||||
|
host = event.host
|
||||||
|
rdg_flap = False
|
||||||
|
rdg_flap_pair_event_id: int | None = None
|
||||||
|
rdg_flap_qwinsta_event_id: int | None = None
|
||||||
|
if db is not None:
|
||||||
|
rdg_flap, rdg_flap_pair_event_id, rdg_flap_qwinsta_event_id = resolve_rdg_flap_summary(
|
||||||
|
db, event
|
||||||
|
)
|
||||||
|
|
||||||
|
title = event.title
|
||||||
|
summary = event.summary
|
||||||
|
rdg_access_path: str | None = None
|
||||||
|
rdg_qwinsta_enabled = False
|
||||||
|
rdg_display = build_rdg_display(event, db)
|
||||||
|
if rdg_display is not None:
|
||||||
|
title = rdg_display.title
|
||||||
|
summary = rdg_display.summary
|
||||||
|
rdg_access_path = rdg_display.access_path
|
||||||
|
rdg_qwinsta_enabled = rdg_display.qwinsta_enabled
|
||||||
|
|
||||||
|
return EventSummary(
|
||||||
|
id=event.id,
|
||||||
|
event_id=event.event_id,
|
||||||
|
host_id=event.host_id,
|
||||||
|
hostname=host.hostname,
|
||||||
|
display_name=host.display_name,
|
||||||
|
product_version=host.product_version,
|
||||||
|
occurred_at=event.occurred_at,
|
||||||
|
received_at=event.received_at,
|
||||||
|
category=event.category,
|
||||||
|
type=event.type,
|
||||||
|
severity=event.severity,
|
||||||
|
title=title,
|
||||||
|
summary=summary,
|
||||||
|
actor_user=extract_event_actor_user(event.type, event.details),
|
||||||
|
session_duration_sec=extract_session_duration_sec(
|
||||||
|
event.details if isinstance(event.details, dict) else None
|
||||||
|
),
|
||||||
|
rdg_flap=rdg_flap,
|
||||||
|
rdg_flap_pair_event_id=rdg_flap_pair_event_id,
|
||||||
|
rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
|
||||||
|
rdg_access_path=rdg_access_path,
|
||||||
|
rdg_qwinsta_enabled=rdg_qwinsta_enabled,
|
||||||
|
session_terminated=event_session_terminated(event, db=db),
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user