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
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")