chore(home): mirror from kalinamall (9883e6a) with papatramp URLs

This commit is contained in:
2026-07-14 20:43:52 +10:00
commit ed4e78f6c3
312 changed files with 42790 additions and 0 deletions
+21
View File
@@ -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"]
+42
View File
@@ -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 |
+42
View File
@@ -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
+46
View File
@@ -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()
+25
View File
@@ -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"}
+90
View File
@@ -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")
+48
View File
@@ -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")
+35
View File
@@ -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")
View File
View File
View File
+76
View File
@@ -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"'},
)
+90
View File
@@ -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
+167
View File
@@ -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)
+390
View File
@@ -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,
)
+53
View File
@@ -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)
+932
View File
@@ -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,
)
+378
View File
@@ -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
+376
View File
@@ -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}
+17
View File
@@ -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)
+860
View File
@@ -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)
+88
View File
@@ -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"},
)
+30
View File
@@ -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)
+144
View File
@@ -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")
+55
View File
@@ -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
+352
View File
@@ -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
+37
View File
@@ -0,0 +1,37 @@
"""httpOnly cookie auth for SSE (EventSource cannot send Authorization header)."""
from __future__ import annotations
from fastapi import Response
from app.config import Settings
STREAM_COOKIE_NAME = "sac_stream"
STREAM_COOKIE_PATH = "/api/v1/stream"
def stream_cookie_kwargs(settings: Settings) -> dict[str, object]:
secure = settings.sac_public_url.lower().startswith("https://")
return {
"key": STREAM_COOKIE_NAME,
"httponly": True,
"secure": secure,
"samesite": "strict",
"path": STREAM_COOKIE_PATH,
"max_age": max(60, int(settings.jwt_expire_minutes) * 60),
}
def set_stream_auth_cookie(response: Response, token: str, settings: Settings) -> None:
response.set_cookie(value=token, **stream_cookie_kwargs(settings))
def clear_stream_auth_cookie(response: Response, settings: Settings) -> None:
kwargs = stream_cookie_kwargs(settings)
response.delete_cookie(
key=kwargs["key"],
path=kwargs["path"],
secure=bool(kwargs["secure"]),
httponly=True,
samesite="strict",
)
+170
View File
@@ -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()
+40
View File
@@ -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",
}
+26
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Фоновые задачи SAC (retention и др.)."""
+45
View File
@@ -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()
+38
View File
@@ -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())
+36
View File
@@ -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())
+163
View File
@@ -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)
+38
View File
@@ -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",
]
+27
View File
@@ -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()
+17
View File
@@ -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())
+30
View File
@@ -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,
)
+45
View File
@@ -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")
+21
View File
@@ -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,
)
+25
View File
@@ -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)
+21
View File
@@ -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))
+19
View File
@@ -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)
+39
View File
@@ -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()
+40
View File
@@ -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
)
+21
View File
@@ -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())
+104
View File
@@ -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
+52
View File
@@ -0,0 +1,52 @@
"""Fail-fast checks for insecure production defaults."""
from __future__ import annotations
import logging
from urllib.parse import urlparse
from app.config import Settings
logger = logging.getLogger("sac")
_WEAK_JWT_SECRETS = frozenset(
{
"",
"change-me-in-production",
"change-me-openssl-rand-hex-32",
"CHANGE_ME_openssl_rand_hex_32",
}
)
def _is_local_dev_url(public_url: str) -> bool:
parsed = urlparse(public_url.strip() or "http://localhost:8000")
host = (parsed.hostname or "").lower()
return host in {"localhost", "127.0.0.1", "::1", "testserver"}
def validate_security_settings(settings: Settings) -> None:
"""Raise on dangerous defaults when SAC_SECURITY_ENFORCE=true."""
if not settings.sac_security_enforce:
return
if settings.jwt_secret in _WEAK_JWT_SECRETS:
raise RuntimeError(
"JWT_SECRET is missing or uses an insecure default. "
"Set a random value in sac-api.env (openssl rand -hex 32)."
)
if settings.cors_origins.strip() == "*" and not _is_local_dev_url(settings.sac_public_url):
raise RuntimeError(
"CORS_ORIGINS=* is not allowed with SAC_SECURITY_ENFORCE=true on non-local SAC_PUBLIC_URL. "
"Set CORS_ORIGINS to your UI origin, e.g. https://sac.example.com"
)
def warn_relaxed_security(settings: Settings) -> None:
if settings.sac_security_enforce:
return
if settings.jwt_secret in _WEAK_JWT_SECRETS:
logger.warning("JWT_SECRET uses insecure default — set a strong secret before production")
if settings.cors_origins.strip() == "*":
logger.warning("CORS_ORIGINS=* — restrict to explicit origins in production")
+109
View File
@@ -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))
+308
View File
@@ -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,
)
+74
View File
@@ -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
+31
View File
@@ -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],
}
+325
View File
@@ -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
+149
View File
@@ -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
+27
View File
@@ -0,0 +1,27 @@
"""Resolve client IP behind reverse proxy (nginx $proxy_add_x_forwarded_for)."""
from __future__ import annotations
from fastapi import Request
def client_ip_from_request(request: Request) -> str:
"""Client IP for rate limiting.
nginx with ``proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for``
appends the real peer address as the *last* hop. Spoofed values in the
incoming header therefore cannot replace the actual client IP.
"""
if request.client and request.client.host:
direct = request.client.host.strip()
else:
direct = "unknown"
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
if not forwarded:
return direct[:64]
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if not parts:
return direct[:64]
return parts[-1][:64]
+368
View File
@@ -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
+492
View File
@@ -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
+131
View File
@@ -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)
+50
View File
@@ -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)
+57
View File
@@ -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
+132
View File
@@ -0,0 +1,132 @@
"""Best-effort integration with fail2ban for SSH login bans (OS level, not SAC DB)."""
from __future__ import annotations
import logging
import re
import subprocess
from dataclasses import dataclass
from app.config import get_settings
logger = logging.getLogger(__name__)
_BANNED_IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
@dataclass(frozen=True)
class Fail2banActionResult:
ok: bool
message: str
def _ssh_jail() -> str:
settings = get_settings()
jail = (getattr(settings, "sac_fail2ban_ssh_jail", None) or "sshd").strip()
return jail or "sshd"
def _run_fail2ban(args: list[str], *, timeout: int = 15) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["fail2ban-client", *args],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def fail2ban_available() -> bool:
try:
proc = _run_fail2ban(["ping"], timeout=5)
return proc.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def list_ssh_banned_ips() -> list[str]:
if not fail2ban_available():
return []
jail = _ssh_jail()
try:
proc = _run_fail2ban(["status", jail])
except (OSError, subprocess.SubprocessError) as exc:
logger.warning("fail2ban status failed: %s", exc)
return []
if proc.returncode != 0:
return []
text = (proc.stdout or "") + "\n" + (proc.stderr or "")
ips: list[str] = []
capture = False
for line in text.splitlines():
lower = line.strip().lower()
if "banned ip list" in lower:
capture = True
tail = line.split(":", 1)[-1]
ips.extend(_BANNED_IP_RE.findall(tail))
continue
if capture:
if not line.strip():
break
ips.extend(_BANNED_IP_RE.findall(line))
# preserve order, unique
seen: set[str] = set()
out: list[str] = []
for ip in ips:
if ip not in seen:
seen.add(ip)
out.append(ip)
return out
def unban_ssh_ip(ip_address: str) -> Fail2banActionResult:
ip = (ip_address or "").strip()
if not ip:
return Fail2banActionResult(ok=False, message="empty ip")
if not fail2ban_available():
return Fail2banActionResult(ok=False, message="fail2ban-client недоступен на сервере SAC")
jail = _ssh_jail()
try:
proc = _run_fail2ban(["set", jail, "unbanip", ip])
except (OSError, subprocess.SubprocessError) as exc:
return Fail2banActionResult(ok=False, message=str(exc))
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "unban failed").strip()
return Fail2banActionResult(ok=False, message=err[:500])
return Fail2banActionResult(ok=True, message=f"SSH: IP {ip} разблокирован (jail {jail})")
def sync_fail2ban_ignore_ips(ip_addresses: list[str]) -> Fail2banActionResult:
settings = get_settings()
if not getattr(settings, "sac_fail2ban_sync_enabled", False):
return Fail2banActionResult(ok=True, message="sync отключён (SAC_FAIL2BAN_SYNC_ENABLED=false)")
path = (getattr(settings, "sac_fail2ban_ignoreip_file", None) or "").strip()
if not path:
return Fail2banActionResult(ok=True, message="файл ignoreip не задан (SAC_FAIL2BAN_IGNOREIP_FILE)")
jail = _ssh_jail()
base_ignore = "127.0.0.1/8 ::1"
extra = " ".join(ip for ip in ip_addresses if ip)
ignore_line = f"{base_ignore} {extra}".strip()
content = (
f"# Managed by SAC — login IP whitelist for fail2ban\n"
f"[{jail}]\n"
f"ignoreip = {ignore_line}\n"
)
try:
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
except OSError as exc:
return Fail2banActionResult(ok=False, message=f"не удалось записать {path}: {exc}")
if not fail2ban_available():
return Fail2banActionResult(ok=True, message=f"файл записан ({path}); fail2ban reload пропущен")
try:
proc = _run_fail2ban(["reload"], timeout=30)
except (OSError, subprocess.SubprocessError) as exc:
return Fail2banActionResult(ok=False, message=f"файл записан, reload failed: {exc}")
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "reload failed").strip()
return Fail2banActionResult(ok=False, message=f"файл записан, reload: {err[:300]}")
return Fail2banActionResult(ok=True, message=f"fail2ban ignoreip обновлён ({path})")
+51
View File
@@ -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),
)
+100
View File
@@ -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