chore(home): mirror from kalinamall (9883e6a) with papatramp URLs
This commit is contained in:
@@ -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),
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Hide selected event types from SAC UI/API event lists."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
from app.models.event import Event
|
||||
from app.models.event_type_visibility import EventTypeVisibility
|
||||
|
||||
|
||||
def get_hidden_event_types(db: Session) -> frozenset[str]:
|
||||
rows = db.scalars(
|
||||
select(EventTypeVisibility.event_type).where(EventTypeVisibility.show_in_events.is_(False))
|
||||
).all()
|
||||
return frozenset(rows)
|
||||
|
||||
|
||||
def get_visibility_map(db: Session) -> dict[str, bool]:
|
||||
rows = db.scalars(select(EventTypeVisibility)).all()
|
||||
return {row.event_type: row.show_in_events for row in rows}
|
||||
|
||||
|
||||
def show_in_events_for(event_type: str, *, hidden: frozenset[str]) -> bool:
|
||||
return event_type not in hidden
|
||||
|
||||
|
||||
def event_type_notifications_enabled(event_type: str, db: Session | None) -> bool:
|
||||
"""Outbound alerts (Telegram/push/email/webhook) only for types visible in events UI."""
|
||||
if db is None:
|
||||
return True
|
||||
hidden = get_hidden_event_types(db)
|
||||
return show_in_events_for(event_type, hidden=hidden)
|
||||
|
||||
|
||||
def visibility_type_filter(hidden: frozenset[str]) -> ColumnElement[bool] | None:
|
||||
if not hidden:
|
||||
return None
|
||||
return Event.type.not_in(tuple(sorted(hidden)))
|
||||
|
||||
|
||||
def replace_event_visibility(db: Session, visibility: dict[str, bool | None]) -> dict[str, bool]:
|
||||
stored = get_visibility_map(db)
|
||||
for event_type, show in visibility.items():
|
||||
normalized_type = event_type.strip()
|
||||
if not normalized_type:
|
||||
raise ValueError("event_type is required")
|
||||
if show is None or show is True:
|
||||
db.execute(
|
||||
delete(EventTypeVisibility).where(EventTypeVisibility.event_type == normalized_type)
|
||||
)
|
||||
stored.pop(normalized_type, None)
|
||||
continue
|
||||
if show is False:
|
||||
row = db.get(EventTypeVisibility, normalized_type)
|
||||
if row is None:
|
||||
row = EventTypeVisibility(event_type=normalized_type, show_in_events=False)
|
||||
db.add(row)
|
||||
else:
|
||||
row.show_in_events = False
|
||||
stored[normalized_type] = False
|
||||
else:
|
||||
raise ValueError("show_in_events must be true, false, or null")
|
||||
db.flush()
|
||||
return stored
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Best-effort integration with fail2ban for SSH login bans (OS level, not SAC DB)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BANNED_IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fail2banActionResult:
|
||||
ok: bool
|
||||
message: str
|
||||
|
||||
|
||||
def _ssh_jail() -> str:
|
||||
settings = get_settings()
|
||||
jail = (getattr(settings, "sac_fail2ban_ssh_jail", None) or "sshd").strip()
|
||||
return jail or "sshd"
|
||||
|
||||
|
||||
def _run_fail2ban(args: list[str], *, timeout: int = 15) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["fail2ban-client", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def fail2ban_available() -> bool:
|
||||
try:
|
||||
proc = _run_fail2ban(["ping"], timeout=5)
|
||||
return proc.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
def list_ssh_banned_ips() -> list[str]:
|
||||
if not fail2ban_available():
|
||||
return []
|
||||
jail = _ssh_jail()
|
||||
try:
|
||||
proc = _run_fail2ban(["status", jail])
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
logger.warning("fail2ban status failed: %s", exc)
|
||||
return []
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
text = (proc.stdout or "") + "\n" + (proc.stderr or "")
|
||||
ips: list[str] = []
|
||||
capture = False
|
||||
for line in text.splitlines():
|
||||
lower = line.strip().lower()
|
||||
if "banned ip list" in lower:
|
||||
capture = True
|
||||
tail = line.split(":", 1)[-1]
|
||||
ips.extend(_BANNED_IP_RE.findall(tail))
|
||||
continue
|
||||
if capture:
|
||||
if not line.strip():
|
||||
break
|
||||
ips.extend(_BANNED_IP_RE.findall(line))
|
||||
# preserve order, unique
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for ip in ips:
|
||||
if ip not in seen:
|
||||
seen.add(ip)
|
||||
out.append(ip)
|
||||
return out
|
||||
|
||||
|
||||
def unban_ssh_ip(ip_address: str) -> Fail2banActionResult:
|
||||
ip = (ip_address or "").strip()
|
||||
if not ip:
|
||||
return Fail2banActionResult(ok=False, message="empty ip")
|
||||
if not fail2ban_available():
|
||||
return Fail2banActionResult(ok=False, message="fail2ban-client недоступен на сервере SAC")
|
||||
jail = _ssh_jail()
|
||||
try:
|
||||
proc = _run_fail2ban(["set", jail, "unbanip", ip])
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return Fail2banActionResult(ok=False, message=str(exc))
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "unban failed").strip()
|
||||
return Fail2banActionResult(ok=False, message=err[:500])
|
||||
return Fail2banActionResult(ok=True, message=f"SSH: IP {ip} разблокирован (jail {jail})")
|
||||
|
||||
|
||||
def sync_fail2ban_ignore_ips(ip_addresses: list[str]) -> Fail2banActionResult:
|
||||
settings = get_settings()
|
||||
if not getattr(settings, "sac_fail2ban_sync_enabled", False):
|
||||
return Fail2banActionResult(ok=True, message="sync отключён (SAC_FAIL2BAN_SYNC_ENABLED=false)")
|
||||
path = (getattr(settings, "sac_fail2ban_ignoreip_file", None) or "").strip()
|
||||
if not path:
|
||||
return Fail2banActionResult(ok=True, message="файл ignoreip не задан (SAC_FAIL2BAN_IGNOREIP_FILE)")
|
||||
|
||||
jail = _ssh_jail()
|
||||
base_ignore = "127.0.0.1/8 ::1"
|
||||
extra = " ".join(ip for ip in ip_addresses if ip)
|
||||
ignore_line = f"{base_ignore} {extra}".strip()
|
||||
content = (
|
||||
f"# Managed by SAC — login IP whitelist for fail2ban\n"
|
||||
f"[{jail}]\n"
|
||||
f"ignoreip = {ignore_line}\n"
|
||||
)
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
except OSError as exc:
|
||||
return Fail2banActionResult(ok=False, message=f"не удалось записать {path}: {exc}")
|
||||
|
||||
if not fail2ban_available():
|
||||
return Fail2banActionResult(ok=True, message=f"файл записан ({path}); fail2ban reload пропущен")
|
||||
|
||||
try:
|
||||
proc = _run_fail2ban(["reload"], timeout=30)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return Fail2banActionResult(ok=False, message=f"файл записан, reload failed: {exc}")
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "reload failed").strip()
|
||||
return Fail2banActionResult(ok=False, message=f"файл записан, reload: {err[:300]}")
|
||||
return Fail2banActionResult(ok=True, message=f"fail2ban ignoreip обновлён ({path})")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Удаление хоста и связанных events/problems (ручная очистка UI)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import delete, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host, Problem, ProblemEvent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostDeleteResult:
|
||||
host_id: int
|
||||
hostname: str
|
||||
deleted_events: int
|
||||
deleted_problems: int
|
||||
|
||||
|
||||
def delete_host_and_related(db: Session, host_id: int) -> HostDeleteResult | None:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
return None
|
||||
|
||||
hostname = host.hostname
|
||||
event_ids = list(db.scalars(select(Event.id).where(Event.host_id == host_id)).all())
|
||||
problem_ids = list(db.scalars(select(Problem.id).where(Problem.host_id == host_id)).all())
|
||||
|
||||
if event_ids or problem_ids:
|
||||
link_filter = []
|
||||
if event_ids:
|
||||
link_filter.append(ProblemEvent.event_id.in_(event_ids))
|
||||
if problem_ids:
|
||||
link_filter.append(ProblemEvent.problem_id.in_(problem_ids))
|
||||
db.execute(delete(ProblemEvent).where(or_(*link_filter)))
|
||||
|
||||
if problem_ids:
|
||||
db.execute(delete(Problem).where(Problem.id.in_(problem_ids)))
|
||||
if event_ids:
|
||||
db.execute(delete(Event).where(Event.id.in_(event_ids)))
|
||||
|
||||
db.delete(host)
|
||||
db.flush()
|
||||
|
||||
return HostDeleteResult(
|
||||
host_id=host_id,
|
||||
hostname=hostname,
|
||||
deleted_events=len(event_ids),
|
||||
deleted_problems=len(problem_ids),
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Статус агентов по событиям agent.heartbeat / report.daily.* в БД."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host
|
||||
|
||||
HEARTBEAT_TYPE = "agent.heartbeat"
|
||||
DAILY_REPORT_SSH = "report.daily.ssh"
|
||||
DAILY_REPORT_RDP = "report.daily.rdp"
|
||||
DAILY_REPORT_TYPES = (DAILY_REPORT_SSH, DAILY_REPORT_RDP)
|
||||
|
||||
|
||||
def max_event_time_by_host(db: Session, event_type: str) -> dict[int, datetime]:
|
||||
rows = db.execute(
|
||||
select(Event.host_id, func.max(Event.received_at))
|
||||
.where(Event.type == event_type)
|
||||
.group_by(Event.host_id)
|
||||
).all()
|
||||
return {int(host_id): ts for host_id, ts in rows if ts is not None}
|
||||
|
||||
|
||||
def max_daily_report_time_by_host(db: Session) -> dict[int, datetime]:
|
||||
rows = db.execute(
|
||||
select(Event.host_id, func.max(Event.received_at))
|
||||
.where(Event.type.in_(DAILY_REPORT_TYPES))
|
||||
.group_by(Event.host_id)
|
||||
).all()
|
||||
return {int(host_id): ts for host_id, ts in rows if ts is not None}
|
||||
|
||||
|
||||
def agent_status(
|
||||
last_heartbeat_at: datetime | None,
|
||||
*,
|
||||
stale_minutes: int,
|
||||
now: datetime | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
online — heartbeat не старше порога;
|
||||
stale — был хост, но heartbeat устарел или отсутствует;
|
||||
unknown — heartbeat ещё не приходил.
|
||||
"""
|
||||
if last_heartbeat_at is None:
|
||||
return "unknown"
|
||||
ref = now or datetime.now(timezone.utc)
|
||||
hb = last_heartbeat_at
|
||||
if hb.tzinfo is None:
|
||||
hb = hb.replace(tzinfo=timezone.utc)
|
||||
age_sec = (ref - hb).total_seconds()
|
||||
if age_sec > stale_minutes * 60:
|
||||
return "stale"
|
||||
return "online"
|
||||
|
||||
|
||||
def count_stale_hosts(db: Session, stale_minutes: int) -> int:
|
||||
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
||||
host_ids = db.scalars(select(Host.id)).all()
|
||||
return sum(
|
||||
1
|
||||
for host_id in host_ids
|
||||
if agent_status(hb_map.get(host_id), stale_minutes=stale_minutes) == "stale"
|
||||
)
|
||||
|
||||
|
||||
def apply_agent_status_host_filter(
|
||||
stmt,
|
||||
count_stmt,
|
||||
agent_status_filter: str,
|
||||
*,
|
||||
stale_minutes: int,
|
||||
):
|
||||
"""Ограничить выборку Host по online / stale / unknown (как в agent_status)."""
|
||||
from datetime import timedelta
|
||||
|
||||
hb_subq = (
|
||||
select(Event.host_id, func.max(Event.received_at).label("last_hb"))
|
||||
.where(Event.type == HEARTBEAT_TYPE)
|
||||
.group_by(Event.host_id)
|
||||
.subquery()
|
||||
)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes)
|
||||
|
||||
if agent_status_filter == "online":
|
||||
join = stmt.join(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
count_join = count_stmt.join(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
cond = hb_subq.c.last_hb >= cutoff
|
||||
return join.where(cond), count_join.where(cond)
|
||||
if agent_status_filter == "stale":
|
||||
join = stmt.join(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
count_join = count_stmt.join(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
cond = hb_subq.c.last_hb < cutoff
|
||||
return join.where(cond), count_join.where(cond)
|
||||
if agent_status_filter == "unknown":
|
||||
join = stmt.outerjoin(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
count_join = count_stmt.outerjoin(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
cond = hb_subq.c.last_hb.is_(None)
|
||||
return join.where(cond), count_join.where(cond)
|
||||
return stmt, count_stmt
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user