chore(home): mirror from kalinamall (9883e6a) with papatramp URLs
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.api_key import get_api_key_auth
|
||||
from app.database import get_db
|
||||
from app.services.agent_commands import (
|
||||
complete_command,
|
||||
resolve_host_by_agent_instance_id,
|
||||
)
|
||||
from app.services.agent_poll import build_agent_poll_payload
|
||||
|
||||
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||
|
||||
|
||||
class AgentCommandResultBody(BaseModel):
|
||||
status: str = Field(pattern="^(completed|failed)$")
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
|
||||
|
||||
class AgentCommandsPollResponse(BaseModel):
|
||||
config_revision: int = 0
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
update: dict[str, Any] = Field(default_factory=dict)
|
||||
commands: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.get("/commands", response_model=AgentCommandsPollResponse)
|
||||
def poll_agent_commands(
|
||||
agent_instance_id: str = Query(..., min_length=1),
|
||||
db: Session = Depends(get_db),
|
||||
_api_key: str = Depends(get_api_key_auth),
|
||||
) -> AgentCommandsPollResponse:
|
||||
host = resolve_host_by_agent_instance_id(db, agent_instance_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown agent_instance_id")
|
||||
payload = build_agent_poll_payload(db, host)
|
||||
return AgentCommandsPollResponse(**payload)
|
||||
|
||||
|
||||
@router.post("/commands/{command_uuid}/result")
|
||||
def post_agent_command_result(
|
||||
command_uuid: str,
|
||||
body: AgentCommandResultBody,
|
||||
db: Session = Depends(get_db),
|
||||
_api_key: str = Depends(get_api_key_auth),
|
||||
) -> dict[str, str]:
|
||||
cmd = complete_command(
|
||||
db,
|
||||
command_uuid,
|
||||
status=body.status,
|
||||
stdout=body.stdout,
|
||||
stderr=body.stderr,
|
||||
)
|
||||
if cmd is None:
|
||||
raise HTTPException(status_code=404, detail="Command not found")
|
||||
db.commit()
|
||||
return {"status": cmd.status, "command_uuid": cmd.command_uuid}
|
||||
|
||||
|
||||
@router.get("/rdp-bundle/{token}")
|
||||
def download_rdp_bundle(token: str) -> Response:
|
||||
from app.services.rdp_bundle_delivery import get_rdp_bundle_zip
|
||||
|
||||
data = get_rdp_bundle_zip(token)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="Bundle not found or expired")
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": 'attachment; filename="sac-rdp-bundle.zip"'},
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
|
||||
from app.auth.stream_cookie import clear_stream_auth_cookie, set_stream_auth_cookie
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.services.login_rate_limit import (
|
||||
ensure_login_allowed,
|
||||
record_login_failure,
|
||||
record_login_success,
|
||||
)
|
||||
from app.services.user_auth import authenticate_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
username: str
|
||||
role: str
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
username: str
|
||||
role: str
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> JSONResponse:
|
||||
settings = get_settings()
|
||||
if not settings.sac_admin_password and not _has_any_user(db):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
|
||||
)
|
||||
|
||||
ip_address = ensure_login_allowed(db, request)
|
||||
|
||||
user = authenticate_user(db, body.username, body.password)
|
||||
if user is None:
|
||||
record_login_failure(db, ip_address=ip_address, username=body.username)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
record_login_success(db, ip_address=ip_address, username=user.username)
|
||||
db.commit()
|
||||
|
||||
token = create_access_token(user.username, user.role)
|
||||
payload = TokenResponse(
|
||||
access_token=token,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
)
|
||||
response = JSONResponse(content=payload.model_dump())
|
||||
set_stream_auth_cookie(response, token, settings)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
def logout() -> Response:
|
||||
settings = get_settings()
|
||||
response = Response(status_code=204)
|
||||
clear_stream_auth_cookie(response, settings)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
|
||||
return MeResponse(username=current_user.username, role=current_user.role)
|
||||
|
||||
|
||||
def _has_any_user(db: Session) -> bool:
|
||||
from app.models.user import User
|
||||
|
||||
try:
|
||||
count = db.scalar(select(func.count()).select_from(User)) or 0
|
||||
return count > 0
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return False
|
||||
@@ -0,0 +1,167 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.auth.jwt_auth import get_current_user
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import Event, Host, Problem
|
||||
from app.schemas.list_models import EventSummary
|
||||
from app.services.event_summary import event_to_summary
|
||||
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
|
||||
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
|
||||
|
||||
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
|
||||
|
||||
|
||||
def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]:
|
||||
"""Последние события по времени приёма ingest (received_at)."""
|
||||
hidden = get_hidden_event_types(db)
|
||||
type_filter = visibility_type_filter(hidden)
|
||||
stmt = (
|
||||
select(Event)
|
||||
.join(Host)
|
||||
.options(joinedload(Event.host))
|
||||
.order_by(Event.received_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if type_filter is not None:
|
||||
stmt = stmt.where(type_filter)
|
||||
rows = db.scalars(stmt).all()
|
||||
return [event_to_summary(e, db) for e in rows]
|
||||
|
||||
|
||||
class TopHostItem(BaseModel):
|
||||
host_id: int
|
||||
hostname: str
|
||||
display_name: str | None = None
|
||||
count: int
|
||||
|
||||
|
||||
class TopTypeItem(BaseModel):
|
||||
type: str
|
||||
count: int
|
||||
|
||||
|
||||
class DashboardSummary(BaseModel):
|
||||
events_last_24h: int
|
||||
hosts_total: int
|
||||
hosts_stale: int
|
||||
heartbeats_24h: int
|
||||
daily_reports_24h: int
|
||||
problems_open: int
|
||||
problems_opened_24h: int
|
||||
problems_resolved_24h: int
|
||||
severity_24h: dict[str, int]
|
||||
top_hosts: list[TopHostItem]
|
||||
top_event_types: list[TopTypeItem]
|
||||
recent_events: list[EventSummary]
|
||||
|
||||
|
||||
@router.get("/summary", response_model=DashboardSummary)
|
||||
def dashboard_summary(
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> DashboardSummary:
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
hidden = get_hidden_event_types(db)
|
||||
type_filter = visibility_type_filter(hidden)
|
||||
|
||||
events_24h_stmt = select(func.count()).select_from(Event).where(Event.received_at >= since)
|
||||
if type_filter is not None:
|
||||
events_24h_stmt = events_24h_stmt.where(type_filter)
|
||||
events_24h = db.scalar(events_24h_stmt) or 0
|
||||
hosts_total = db.scalar(select(func.count()).select_from(Host)) or 0
|
||||
settings = get_settings()
|
||||
hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes)
|
||||
heartbeats_24h = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Event)
|
||||
.where(Event.received_at >= since, Event.type == HEARTBEAT_TYPE)
|
||||
) or 0
|
||||
daily_reports_24h = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Event)
|
||||
.where(Event.received_at >= since, Event.type.in_(DAILY_REPORT_TYPES))
|
||||
) or 0
|
||||
problems_open = (
|
||||
db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0
|
||||
)
|
||||
problems_opened_24h = (
|
||||
db.scalar(
|
||||
select(func.count()).select_from(Problem).where(
|
||||
Problem.created_at >= since,
|
||||
Problem.status != "resolved",
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
problems_resolved_24h = (
|
||||
db.scalar(
|
||||
select(func.count()).select_from(Problem).where(
|
||||
Problem.status == "resolved",
|
||||
Problem.updated_at >= since,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
top_host_stmt = (
|
||||
select(Host.id, Host.hostname, Host.display_name, func.count())
|
||||
.select_from(Event)
|
||||
.join(Host, Event.host_id == Host.id)
|
||||
.where(Event.received_at >= since)
|
||||
)
|
||||
if type_filter is not None:
|
||||
top_host_stmt = top_host_stmt.where(type_filter)
|
||||
top_host_rows = db.execute(
|
||||
top_host_stmt.group_by(Host.id, Host.hostname, Host.display_name)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
).all()
|
||||
top_hosts = [
|
||||
TopHostItem(host_id=row[0], hostname=row[1], display_name=row[2], count=row[3]) for row in top_host_rows
|
||||
]
|
||||
|
||||
top_type_stmt = select(Event.type, func.count()).where(Event.received_at >= since)
|
||||
if type_filter is not None:
|
||||
top_type_stmt = top_type_stmt.where(type_filter)
|
||||
top_type_rows = db.execute(
|
||||
top_type_stmt.group_by(Event.type).order_by(func.count().desc()).limit(10)
|
||||
).all()
|
||||
top_event_types = [TopTypeItem(type=row[0], count=row[1]) for row in top_type_rows]
|
||||
|
||||
severity_stmt = select(Event.severity, func.count()).where(Event.received_at >= since)
|
||||
if type_filter is not None:
|
||||
severity_stmt = severity_stmt.where(type_filter)
|
||||
severity_rows = db.execute(severity_stmt.group_by(Event.severity)).all()
|
||||
severity_24h = {row[0]: row[1] for row in severity_rows}
|
||||
|
||||
recent_events = fetch_recent_events(db, limit=8)
|
||||
|
||||
return DashboardSummary(
|
||||
events_last_24h=events_24h,
|
||||
hosts_total=hosts_total,
|
||||
hosts_stale=hosts_stale,
|
||||
heartbeats_24h=heartbeats_24h,
|
||||
daily_reports_24h=daily_reports_24h,
|
||||
problems_open=problems_open,
|
||||
problems_opened_24h=problems_opened_24h,
|
||||
problems_resolved_24h=problems_resolved_24h,
|
||||
severity_24h=severity_24h,
|
||||
top_hosts=top_hosts,
|
||||
top_event_types=top_event_types,
|
||||
recent_events=recent_events,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/recent-events", response_model=list[EventSummary])
|
||||
def dashboard_recent_events(
|
||||
limit: int = Query(8, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> list[EventSummary]:
|
||||
return fetch_recent_events(db, limit=limit)
|
||||
@@ -0,0 +1,390 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.auth.api_key import get_api_key_auth
|
||||
from app.auth.jwt_auth import get_current_user, CurrentUser
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import Event, Host
|
||||
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
||||
from app.utils.sql_like import escape_ilike_pattern
|
||||
from app.services.agent_commands import (
|
||||
command_response_fields,
|
||||
command_to_dict,
|
||||
get_command_by_uuid,
|
||||
)
|
||||
from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
|
||||
from app.services.host_sessions import (
|
||||
event_session_id,
|
||||
event_session_terminated,
|
||||
event_supports_session_terminate,
|
||||
mark_event_session_terminated,
|
||||
terminate_session_for_event,
|
||||
)
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||
from app.services.ssh_connect import (
|
||||
HostNotLinuxError as SshHostNotLinuxError,
|
||||
HostTargetMissingError as SshHostTargetMissingError,
|
||||
)
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
|
||||
from app.services.ingest import ingest_event
|
||||
from app.services.event_summary import event_to_summary
|
||||
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
|
||||
from app.services.problems import maybe_create_problem
|
||||
from app.services.rdp_flap_auto_disconnect import maybe_auto_disconnect_stuck_rdp_session
|
||||
from app.services.schema_validate import validate_event_payload
|
||||
from app.services.notify_dispatch import (
|
||||
AUTH_LOGIN_SUCCESS_TYPES,
|
||||
DAILY_REPORT_EVENT_TYPES,
|
||||
LIFECYCLE_EVENT_TYPE,
|
||||
RDG_CONNECTION_TYPES,
|
||||
notify_auth_login,
|
||||
notify_daily_report,
|
||||
notify_event,
|
||||
notify_lifecycle,
|
||||
notify_problem,
|
||||
notify_rdg_connection,
|
||||
schedule_notify_auth_login,
|
||||
schedule_notify_daily_report,
|
||||
schedule_notify_lifecycle,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
logger = logging.getLogger("sac.ingest")
|
||||
|
||||
|
||||
class IngestResponse(BaseModel):
|
||||
status: str
|
||||
event_id: str
|
||||
created: bool
|
||||
sac_event_url: str
|
||||
problem_id: int | None = None
|
||||
|
||||
|
||||
def _ingest_response(event: Event, *, created: bool) -> IngestResponse:
|
||||
settings = get_settings()
|
||||
base = settings.sac_public_url.rstrip("/")
|
||||
return IngestResponse(
|
||||
status="created" if created else "duplicate",
|
||||
event_id=event.event_id,
|
||||
created=created,
|
||||
sac_event_url=f"{base}/api/v1/events/{event.id}",
|
||||
problem_id=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=IngestResponse)
|
||||
def post_event(
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
_api_key: str = Depends(get_api_key_auth),
|
||||
) -> JSONResponse:
|
||||
errors = validate_event_payload(payload)
|
||||
if errors:
|
||||
event_id_hint = payload.get("event_id") if isinstance(payload.get("event_id"), str) else None
|
||||
logger.warning(
|
||||
"ingest rejected status=422 event_id=%s errors=%s",
|
||||
event_id_hint,
|
||||
errors[:5],
|
||||
)
|
||||
raise HTTPException(status_code=422, detail={"schema_errors": errors[:20]})
|
||||
|
||||
event, created = ingest_event(db, payload)
|
||||
problem = None
|
||||
problem_created = False
|
||||
deferred_daily_report_id: int | None = None
|
||||
deferred_lifecycle_id: int | None = None
|
||||
deferred_auth_login_id: int | None = None
|
||||
if created:
|
||||
problem, problem_created = maybe_create_problem(db, event)
|
||||
maybe_auto_disconnect_stuck_rdp_session(db, event)
|
||||
if event.type in DAILY_REPORT_EVENT_TYPES:
|
||||
deferred_daily_report_id = event.id
|
||||
elif event.type == LIFECYCLE_EVENT_TYPE:
|
||||
deferred_lifecycle_id = event.id
|
||||
elif event.type in AUTH_LOGIN_SUCCESS_TYPES:
|
||||
deferred_auth_login_id = event.id
|
||||
elif event.type in RDG_CONNECTION_TYPES:
|
||||
notify_rdg_connection(event, db=db)
|
||||
else:
|
||||
notify_event(event, db=db)
|
||||
if problem is not None and problem_created:
|
||||
notify_problem(problem, event, db=db)
|
||||
logger.info("ingest created event_id=%s type=%s host_id=%s", event.event_id, event.type, event.host_id)
|
||||
else:
|
||||
logger.info("ingest duplicate event_id=%s", event.event_id)
|
||||
db.commit()
|
||||
|
||||
if deferred_daily_report_id is not None:
|
||||
background_tasks.add_task(schedule_notify_daily_report, deferred_daily_report_id)
|
||||
if deferred_lifecycle_id is not None:
|
||||
background_tasks.add_task(schedule_notify_lifecycle, deferred_lifecycle_id)
|
||||
if deferred_auth_login_id is not None:
|
||||
background_tasks.add_task(schedule_notify_auth_login, deferred_auth_login_id)
|
||||
|
||||
body = _ingest_response(event, created=created)
|
||||
if problem is not None:
|
||||
body.problem_id = problem.id
|
||||
status_code = 201 if created else 409
|
||||
return JSONResponse(status_code=status_code, content=body.model_dump(mode="json"))
|
||||
|
||||
|
||||
def _parse_optional_dt(value: str | None, *, end_of_day: bool = False) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
raw = value.strip()
|
||||
if len(raw) == 10 and raw[4] == "-" and raw[7] == "-":
|
||||
dt = datetime.fromisoformat(raw)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
if end_of_day:
|
||||
return dt.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
@router.get("", response_model=EventListResponse)
|
||||
def list_events(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
severity: str | None = None,
|
||||
type: str | None = None,
|
||||
host_id: int | None = None,
|
||||
hostname: str | None = None,
|
||||
include_hidden: bool = False,
|
||||
from_time: str | None = Query(None, alias="from"),
|
||||
to_time: str | None = Query(None, alias="to"),
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> EventListResponse:
|
||||
if include_hidden and host_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="include_hidden requires host_id",
|
||||
)
|
||||
hidden = get_hidden_event_types(db)
|
||||
type_filter = visibility_type_filter(hidden)
|
||||
if include_hidden and host_id is not None:
|
||||
type_filter = None
|
||||
stmt = select(Event).join(Host).options(joinedload(Event.host))
|
||||
count_stmt = select(func.count()).select_from(Event).join(Host)
|
||||
if type_filter is not None:
|
||||
stmt = stmt.where(type_filter)
|
||||
count_stmt = count_stmt.where(type_filter)
|
||||
|
||||
if severity:
|
||||
stmt = stmt.where(Event.severity == severity)
|
||||
count_stmt = count_stmt.where(Event.severity == severity)
|
||||
if type:
|
||||
normalized = type.strip()
|
||||
if normalized:
|
||||
stmt = stmt.where(Event.type.ilike(f"{normalized}%"))
|
||||
count_stmt = count_stmt.where(Event.type.ilike(f"{normalized}%"))
|
||||
if host_id is not None:
|
||||
stmt = stmt.where(Event.host_id == host_id)
|
||||
count_stmt = count_stmt.where(Event.host_id == host_id)
|
||||
if hostname:
|
||||
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||
stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
dt_from = _parse_optional_dt(from_time)
|
||||
dt_to = _parse_optional_dt(to_time, end_of_day=True)
|
||||
if dt_from:
|
||||
stmt = stmt.where(Event.occurred_at >= dt_from)
|
||||
count_stmt = count_stmt.where(Event.occurred_at >= dt_from)
|
||||
if dt_to:
|
||||
stmt = stmt.where(Event.occurred_at <= dt_to)
|
||||
count_stmt = count_stmt.where(Event.occurred_at <= dt_to)
|
||||
if q:
|
||||
like = f"%{escape_ilike_pattern(q)}%"
|
||||
stmt = stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
|
||||
count_stmt = count_stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
|
||||
|
||||
total = db.scalar(count_stmt) or 0
|
||||
rows = db.scalars(
|
||||
stmt.order_by(Event.occurred_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
).all()
|
||||
|
||||
items = [event_to_summary(e, db) for e in rows]
|
||||
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
class AgentCommandResponse(BaseModel):
|
||||
command_uuid: str
|
||||
command_type: str
|
||||
status: str
|
||||
result_stdout: str | None = None
|
||||
result_stderr: str | None = None
|
||||
created_at: str | None = None
|
||||
completed_at: str | None = None
|
||||
target: str | None = None
|
||||
client_hostname: str | None = None
|
||||
internal_ip: str | None = None
|
||||
|
||||
|
||||
def _agent_command_response(cmd) -> AgentCommandResponse:
|
||||
data = command_to_dict(cmd)
|
||||
extra = command_response_fields(cmd)
|
||||
return AgentCommandResponse(
|
||||
command_uuid=data["id"],
|
||||
command_type=data["type"],
|
||||
status=data["status"],
|
||||
result_stdout=data.get("result_stdout"),
|
||||
result_stderr=data.get("result_stderr"),
|
||||
created_at=data.get("created_at"),
|
||||
completed_at=data.get("completed_at"),
|
||||
target=extra.get("target"),
|
||||
client_hostname=extra.get("client_hostname"),
|
||||
internal_ip=extra.get("internal_ip"),
|
||||
)
|
||||
|
||||
|
||||
class LogoffActionBody(BaseModel):
|
||||
session_id: int
|
||||
|
||||
|
||||
class EventSessionTerminateBody(BaseModel):
|
||||
session_id: str | None = None
|
||||
|
||||
|
||||
class EventSessionTerminateResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
target: str | None = None
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
|
||||
|
||||
@router.post("/{event_db_id}/actions/terminate-session", response_model=EventSessionTerminateResponse)
|
||||
def post_event_terminate_session(
|
||||
event_db_id: int,
|
||||
body: EventSessionTerminateBody | None = Body(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> EventSessionTerminateResponse:
|
||||
event = db.scalar(
|
||||
select(Event).options(joinedload(Event.host)).where(Event.id == event_db_id)
|
||||
)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
if not event_supports_session_terminate(event):
|
||||
raise HTTPException(status_code=400, detail="Event type does not support session terminate")
|
||||
if event_session_terminated(event, db=db):
|
||||
raise HTTPException(status_code=409, detail="Session already terminated for this event")
|
||||
|
||||
if event.host is None:
|
||||
raise HTTPException(status_code=400, detail="Event has no host")
|
||||
linux_cfg = get_effective_linux_admin_for_host(db, event.host)
|
||||
win_cfg = get_effective_win_admin_for_host(db, event.host)
|
||||
sid = (body.session_id if body else None) or event_session_id(event)
|
||||
|
||||
try:
|
||||
result = terminate_session_for_event(
|
||||
event,
|
||||
linux_cfg=linux_cfg,
|
||||
win_cfg=win_cfg,
|
||||
session_id=sid,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except HostNotWindowsError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except HostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SshHostNotLinuxError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SshHostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
response = EventSessionTerminateResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=getattr(result, "target", None),
|
||||
stdout=getattr(result, "stdout", None) or None,
|
||||
stderr=getattr(result, "stderr", None) or None,
|
||||
)
|
||||
if result.ok:
|
||||
mark_event_session_terminated(event, by_username=user.username)
|
||||
db.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse)
|
||||
def post_event_qwinsta(
|
||||
event_db_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> AgentCommandResponse:
|
||||
event = db.get(Event, event_db_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
cmd = execute_qwinsta_via_winrm(db, event, requested_by=user.username)
|
||||
db.commit()
|
||||
return _agent_command_response(cmd)
|
||||
|
||||
|
||||
@router.post("/{event_db_id}/actions/logoff", response_model=AgentCommandResponse)
|
||||
def post_event_logoff(
|
||||
event_db_id: int,
|
||||
body: LogoffActionBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> AgentCommandResponse:
|
||||
event = db.get(Event, event_db_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
cmd = execute_logoff_via_winrm(
|
||||
db,
|
||||
event,
|
||||
session_id=body.session_id,
|
||||
requested_by=user.username,
|
||||
)
|
||||
db.commit()
|
||||
return _agent_command_response(cmd)
|
||||
|
||||
|
||||
@router.get("/{event_db_id}/actions/{command_uuid}", response_model=AgentCommandResponse)
|
||||
def get_event_action_status(
|
||||
event_db_id: int,
|
||||
command_uuid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(get_current_user),
|
||||
) -> AgentCommandResponse:
|
||||
cmd = get_command_by_uuid(db, command_uuid)
|
||||
if cmd is None or cmd.event_id != event_db_id:
|
||||
raise HTTPException(status_code=404, detail="Command not found")
|
||||
return _agent_command_response(cmd)
|
||||
|
||||
|
||||
@router.get("/{event_db_id}", response_model=EventDetail)
|
||||
def get_event(
|
||||
event_db_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> EventDetail:
|
||||
event = db.scalar(
|
||||
select(Event).where(Event.id == event_db_id).options(joinedload(Event.host))
|
||||
)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
base = event_to_summary(event, db)
|
||||
return EventDetail(
|
||||
**base.model_dump(),
|
||||
details=event.details,
|
||||
raw=event.raw,
|
||||
dedup_key=event.dedup_key,
|
||||
correlation_id=event.correlation_id,
|
||||
payload=event.payload,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import Event
|
||||
from app.services.host_health import count_stale_hosts
|
||||
from app.version import APP_NAME, APP_VERSION
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
def build_health_payload(db: Session) -> dict:
|
||||
db_status = "ok"
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
except Exception:
|
||||
db_status = "error"
|
||||
|
||||
hosts_stale = 0
|
||||
last_event_received_at: str | None = None
|
||||
if db_status == "ok":
|
||||
settings = get_settings()
|
||||
hosts_stale = count_stale_hosts(db, settings.sac_heartbeat_stale_minutes)
|
||||
last_received = db.scalar(select(func.max(Event.received_at)))
|
||||
if last_received is not None:
|
||||
last_event_received_at = last_received.isoformat()
|
||||
|
||||
overall = "ok" if db_status == "ok" else "degraded"
|
||||
if db_status == "ok" and hosts_stale > 0:
|
||||
overall = "degraded"
|
||||
|
||||
return {
|
||||
"status": overall,
|
||||
"database": db_status,
|
||||
"service": "security-alert-center",
|
||||
"name": APP_NAME,
|
||||
"version": APP_VERSION,
|
||||
"hosts_stale": hosts_stale,
|
||||
"last_event_received_at": last_event_received_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health(db: Session = Depends(get_db)) -> dict:
|
||||
return build_health_payload(db)
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
def healthz(db: Session = Depends(get_db)) -> dict:
|
||||
"""Kubernetes-style alias; same payload as /health."""
|
||||
return build_health_payload(db)
|
||||
@@ -0,0 +1,932 @@
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.auth.jwt_auth import get_current_user, require_admin
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models import Event, Host
|
||||
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
||||
from app.utils.sql_like import escape_ilike_pattern
|
||||
from app.services.agent_version import (
|
||||
latest_agent_versions_by_product,
|
||||
reference_agent_versions_by_product,
|
||||
)
|
||||
from app.services.agent_git_release import get_git_release_versions
|
||||
from app.services.agent_update import (
|
||||
AgentUpdateNotAllowedError,
|
||||
host_version_status,
|
||||
request_agent_update,
|
||||
)
|
||||
from app.services.host_remote_actions import (
|
||||
RemoteActionAlreadyRunningError,
|
||||
cancel_host_remote_action,
|
||||
clear_stale_running_remote_actions,
|
||||
get_remote_action_status,
|
||||
run_agent_fallback_action,
|
||||
run_ssh_monitor_update_action,
|
||||
start_host_remote_action,
|
||||
)
|
||||
from app.services.agent_update_settings import (
|
||||
PRODUCT_RDP,
|
||||
PRODUCT_SSH,
|
||||
get_effective_agent_update_config,
|
||||
)
|
||||
from app.services.agent_host_config import get_host_agent_settings, update_host_agent_config
|
||||
from app.services.host_sessions import (
|
||||
HostSessionRow,
|
||||
list_linux_sessions,
|
||||
list_windows_sessions,
|
||||
terminate_linux_session,
|
||||
terminate_windows_session,
|
||||
)
|
||||
from app.services.host_delete import delete_host_and_related
|
||||
from app.services.host_mgmt_credentials import (
|
||||
get_host_mgmt_access_view,
|
||||
upsert_host_mgmt_credentials,
|
||||
)
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError,
|
||||
WinAdminNotConfiguredError,
|
||||
WinRmTestResult,
|
||||
iter_winrm_targets,
|
||||
test_winrm_connection,
|
||||
)
|
||||
from app.services.ssh_connect import (
|
||||
HostNotLinuxError as SshHostNotLinuxError,
|
||||
HostTargetMissingError as SshHostTargetMissingError,
|
||||
LinuxAdminNotConfiguredError,
|
||||
SshCommandResult,
|
||||
iter_ssh_targets,
|
||||
run_ssh_monitor_update,
|
||||
test_ssh_connection,
|
||||
)
|
||||
from app.services.host_health import (
|
||||
HEARTBEAT_TYPE,
|
||||
agent_status as compute_agent_status,
|
||||
apply_agent_status_host_filter,
|
||||
max_daily_report_time_by_host,
|
||||
max_event_time_by_host,
|
||||
)
|
||||
from app.services.event_type_visibility import get_hidden_event_types, visibility_type_filter
|
||||
|
||||
router = APIRouter(prefix="/hosts", tags=["hosts"])
|
||||
|
||||
|
||||
def _count_visible_events(db: Session, host_id: int, hidden: frozenset[str]) -> int:
|
||||
stmt = select(func.count()).select_from(Event).where(Event.host_id == host_id)
|
||||
type_filter = visibility_type_filter(hidden)
|
||||
if type_filter is not None:
|
||||
stmt = stmt.where(type_filter)
|
||||
return int(db.scalar(stmt) or 0)
|
||||
|
||||
|
||||
@router.get("", response_model=HostListResponse)
|
||||
def list_hosts(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
product: str | None = None,
|
||||
hostname: str | None = None,
|
||||
agent_status: str | None = Query(None, pattern="^(online|stale|unknown)$"),
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> HostListResponse:
|
||||
stmt = select(Host)
|
||||
count_stmt = select(func.count()).select_from(Host)
|
||||
|
||||
if product:
|
||||
stmt = stmt.where(Host.product == product)
|
||||
count_stmt = count_stmt.where(Host.product == product)
|
||||
if hostname:
|
||||
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||
host_match = Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")
|
||||
stmt = stmt.where(host_match)
|
||||
count_stmt = count_stmt.where(host_match)
|
||||
|
||||
settings = get_settings()
|
||||
if agent_status:
|
||||
stmt, count_stmt = apply_agent_status_host_filter(
|
||||
stmt,
|
||||
count_stmt,
|
||||
agent_status,
|
||||
stale_minutes=settings.sac_heartbeat_stale_minutes,
|
||||
)
|
||||
|
||||
total = db.scalar(count_stmt) or 0
|
||||
rows = db.scalars(
|
||||
stmt.order_by(Host.last_seen_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
).all()
|
||||
|
||||
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
||||
report_map = max_daily_report_time_by_host(db)
|
||||
latest_map = latest_agent_versions_by_product(db)
|
||||
update_cfg = get_effective_agent_update_config(db)
|
||||
git_release = get_git_release_versions(update_cfg, db=db)
|
||||
reference_map = reference_agent_versions_by_product(
|
||||
update_cfg,
|
||||
latest_map,
|
||||
git_versions=git_release.versions,
|
||||
)
|
||||
hidden = get_hidden_event_types(db)
|
||||
|
||||
items: list[HostSummary] = []
|
||||
for host in rows:
|
||||
event_count = _count_visible_events(db, host.id, hidden)
|
||||
last_hb = hb_map.get(host.id)
|
||||
items.append(
|
||||
HostSummary(
|
||||
id=host.id,
|
||||
hostname=host.hostname,
|
||||
display_name=host.display_name,
|
||||
os_family=host.os_family,
|
||||
os_version=host.os_version,
|
||||
product=host.product,
|
||||
product_version=host.product_version,
|
||||
ipv4=host.ipv4,
|
||||
last_seen_at=host.last_seen_at,
|
||||
event_count=int(event_count or 0),
|
||||
last_heartbeat_at=last_hb,
|
||||
last_daily_report_at=report_map.get(host.id),
|
||||
last_inventory_at=host.inventory_updated_at,
|
||||
agent_status=compute_agent_status(
|
||||
last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes
|
||||
),
|
||||
version_status=host_version_status(
|
||||
host,
|
||||
cfg=update_cfg,
|
||||
latest_map=latest_map,
|
||||
git_versions=git_release.versions,
|
||||
),
|
||||
pending_agent_update=bool(host.pending_agent_update),
|
||||
)
|
||||
)
|
||||
|
||||
return HostListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
latest_agent_versions=latest_map,
|
||||
reference_agent_versions=reference_map,
|
||||
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
|
||||
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
|
||||
)
|
||||
|
||||
|
||||
class HostManualAddBody(BaseModel):
|
||||
platform: Literal["windows", "linux"]
|
||||
target: str = Field(..., min_length=1, max_length=253)
|
||||
|
||||
|
||||
def _host_detail_from_model(
|
||||
host: Host,
|
||||
*,
|
||||
db: Session,
|
||||
settings,
|
||||
) -> HostDetail:
|
||||
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
||||
report_map = max_daily_report_time_by_host(db)
|
||||
hidden = get_hidden_event_types(db)
|
||||
event_count = _count_visible_events(db, host.id, hidden)
|
||||
last_hb = hb_map.get(host.id)
|
||||
latest_map = latest_agent_versions_by_product(db)
|
||||
update_cfg = get_effective_agent_update_config(db)
|
||||
git_release = get_git_release_versions(update_cfg, db=db)
|
||||
return HostDetail(
|
||||
id=host.id,
|
||||
hostname=host.hostname,
|
||||
display_name=host.display_name,
|
||||
os_family=host.os_family,
|
||||
os_version=host.os_version,
|
||||
product=host.product,
|
||||
product_version=host.product_version,
|
||||
ipv4=host.ipv4,
|
||||
ipv6=host.ipv6,
|
||||
last_seen_at=host.last_seen_at,
|
||||
event_count=int(event_count or 0),
|
||||
last_heartbeat_at=last_hb,
|
||||
last_daily_report_at=report_map.get(host.id),
|
||||
last_inventory_at=host.inventory_updated_at,
|
||||
agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes),
|
||||
version_status=host_version_status(
|
||||
host,
|
||||
cfg=update_cfg,
|
||||
latest_map=latest_map,
|
||||
git_versions=git_release.versions,
|
||||
),
|
||||
pending_agent_update=bool(host.pending_agent_update),
|
||||
agent_instance_id=host.agent_instance_id,
|
||||
tags=host.tags if isinstance(host.tags, list) else [],
|
||||
use_sac_mode=host.use_sac_mode,
|
||||
inventory=host.inventory if isinstance(host.inventory, dict) else None,
|
||||
inventory_updated_at=host.inventory_updated_at,
|
||||
created_at=host.created_at,
|
||||
ssh_admin_ok=host.ssh_admin_ok,
|
||||
ssh_admin_checked_at=host.ssh_admin_checked_at,
|
||||
pending_update_requested_at=host.pending_update_requested_at,
|
||||
pending_update_target_version=host.pending_update_target_version,
|
||||
agent_config_revision=int(host.agent_config_revision or 0),
|
||||
agent_config=get_host_agent_settings(host) or None,
|
||||
agent_update_state=host.agent_update_state,
|
||||
agent_update_last_at=host.agent_update_last_at,
|
||||
agent_update_last_error=host.agent_update_last_error,
|
||||
)
|
||||
|
||||
|
||||
def _set_ssh_admin_status(host: Host, ok: bool) -> None:
|
||||
host.ssh_admin_ok = ok
|
||||
host.ssh_admin_checked_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@router.get("/{host_id}", response_model=HostDetail)
|
||||
def get_host(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(get_current_user),
|
||||
) -> HostDetail:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
settings = get_settings()
|
||||
return _host_detail_from_model(host, db=db, settings=settings)
|
||||
|
||||
|
||||
class HostDeleteResponse(BaseModel):
|
||||
status: str
|
||||
host_id: int
|
||||
hostname: str
|
||||
deleted_events: int
|
||||
deleted_problems: int
|
||||
|
||||
|
||||
class HostWinRmTestResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
hostname: str | None = None
|
||||
|
||||
|
||||
class HostSshActionResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
exit_code: int | None = None
|
||||
product_version: str | None = None
|
||||
ssh_admin_ok: bool | None = None
|
||||
|
||||
|
||||
class HostAgentUpdateRequestResponse(BaseModel):
|
||||
status: str
|
||||
pending_agent_update: bool
|
||||
target_version: str | None = None
|
||||
agent_update_state: str | None = None
|
||||
|
||||
|
||||
class HostAgentConfigUpdate(BaseModel):
|
||||
settings: dict[str, object] = {}
|
||||
|
||||
|
||||
class HostAgentConfigResponse(BaseModel):
|
||||
config_revision: int
|
||||
settings: dict[str, object]
|
||||
|
||||
|
||||
class HostMgmtAccessResponse(BaseModel):
|
||||
host_id: int
|
||||
has_override: bool
|
||||
user: str | None = None
|
||||
password_set: bool = False
|
||||
password_hint: str | None = None
|
||||
effective_source: str
|
||||
effective_configured: bool
|
||||
effective_user: str | None = None
|
||||
|
||||
|
||||
class HostMgmtAccessUpdate(BaseModel):
|
||||
user: str | None = None
|
||||
password: str | None = None
|
||||
clear: bool = False
|
||||
|
||||
|
||||
class HostRemoteActionStartResponse(BaseModel):
|
||||
status: str
|
||||
host_id: int
|
||||
title: str
|
||||
message: str
|
||||
|
||||
|
||||
@router.post(
|
||||
"/manual-add",
|
||||
response_model=HostRemoteActionStartResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
def post_host_manual_add(
|
||||
body: HostManualAddBody,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostRemoteActionStartResponse:
|
||||
from app.services.host_manual_add import ManualHostAddError, prepare_manual_host_add
|
||||
|
||||
try:
|
||||
host = prepare_manual_host_add(db, platform=body.platform, target=body.target)
|
||||
except ManualHostAddError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
if body.platform == "windows":
|
||||
title = f"Ручное добавление Windows: {host.hostname}"
|
||||
else:
|
||||
title = f"Ручное добавление Linux: {host.hostname}"
|
||||
|
||||
try:
|
||||
start_host_remote_action(
|
||||
db,
|
||||
host,
|
||||
title=title,
|
||||
runner=run_agent_fallback_action,
|
||||
)
|
||||
except RemoteActionAlreadyRunningError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
return HostRemoteActionStartResponse(
|
||||
status="running",
|
||||
host_id=host.id,
|
||||
title=title,
|
||||
message="Проверка пройдена, установка агента запущена на сервере SAC",
|
||||
)
|
||||
|
||||
|
||||
class HostRemoteActionJobResponse(BaseModel):
|
||||
active: bool
|
||||
host_id: int
|
||||
hostname: str | None = None
|
||||
status: str | None = None
|
||||
title: str = ""
|
||||
message: str = ""
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
output: str | None = None
|
||||
ok: bool | None = None
|
||||
exit_code: int | None = None
|
||||
product_version: str | None = None
|
||||
target: str | None = None
|
||||
agent_update_state: str | None = None
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
|
||||
|
||||
class HostSessionItem(BaseModel):
|
||||
session_id: str
|
||||
user: str
|
||||
tty: str | None = None
|
||||
state: str | None = None
|
||||
source_ip: str | None = None
|
||||
session_name: str | None = None
|
||||
|
||||
|
||||
class HostSessionsResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
target: str | None = None
|
||||
sessions: list[HostSessionItem]
|
||||
|
||||
|
||||
class HostSessionTerminateBody(BaseModel):
|
||||
session_id: str
|
||||
|
||||
|
||||
class HostSessionTerminateResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
target: str | None = None
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
|
||||
|
||||
def _session_items(rows: list[HostSessionRow]) -> list[HostSessionItem]:
|
||||
return [
|
||||
HostSessionItem(
|
||||
session_id=r.session_id,
|
||||
user=r.user,
|
||||
tty=r.tty,
|
||||
state=r.state,
|
||||
source_ip=r.source_ip,
|
||||
session_name=r.session_name,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _ssh_action_response(
|
||||
result: SshCommandResult,
|
||||
*,
|
||||
attempts: list[str] | None = None,
|
||||
ssh_admin_ok: bool | None = None,
|
||||
) -> HostSshActionResponse:
|
||||
message = result.message
|
||||
if attempts:
|
||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||
return HostSshActionResponse(
|
||||
ok=result.ok,
|
||||
message=message,
|
||||
target=result.target,
|
||||
stdout=result.stdout or None,
|
||||
stderr=result.stderr or None,
|
||||
exit_code=result.exit_code,
|
||||
product_version=result.agent_version,
|
||||
ssh_admin_ok=ssh_admin_ok,
|
||||
)
|
||||
|
||||
|
||||
def _run_ssh_action_on_host(
|
||||
host: Host,
|
||||
cfg,
|
||||
*,
|
||||
action,
|
||||
) -> HostSshActionResponse:
|
||||
try:
|
||||
targets = iter_ssh_targets(host)
|
||||
except SshHostNotLinuxError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SshHostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
last_result: SshCommandResult | None = None
|
||||
attempts: list[str] = []
|
||||
for target in targets:
|
||||
try:
|
||||
result = action(target=target, user=cfg.user, password=cfg.password)
|
||||
except LinuxAdminNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
last_result = result
|
||||
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
|
||||
if result.ok:
|
||||
return _ssh_action_response(result, attempts=attempts if len(attempts) > 1 else None)
|
||||
|
||||
assert last_result is not None
|
||||
return _ssh_action_response(last_result, attempts=attempts if len(attempts) > 1 else None)
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse)
|
||||
def test_host_winrm(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostWinRmTestResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
cfg = get_effective_win_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
|
||||
try:
|
||||
targets = iter_winrm_targets(host)
|
||||
except HostNotWindowsError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except HostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
last_result = None
|
||||
attempts: list[str] = []
|
||||
for target in targets:
|
||||
try:
|
||||
result = test_winrm_connection(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
)
|
||||
except WinAdminNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
last_result = result
|
||||
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
|
||||
if result.ok:
|
||||
if len(targets) > 1:
|
||||
result = WinRmTestResult(
|
||||
ok=True,
|
||||
message=f"{result.message} (пробовали: {', '.join(attempts)})",
|
||||
target=result.target,
|
||||
hostname=result.hostname,
|
||||
)
|
||||
return HostWinRmTestResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target,
|
||||
hostname=result.hostname,
|
||||
)
|
||||
|
||||
assert last_result is not None
|
||||
message = last_result.message
|
||||
if len(attempts) > 1:
|
||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||
return HostWinRmTestResponse(
|
||||
ok=False,
|
||||
message=message,
|
||||
target=last_result.target,
|
||||
hostname=last_result.hostname,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/ssh-test", response_model=HostSshActionResponse)
|
||||
def test_host_ssh(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostSshActionResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
|
||||
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
|
||||
_set_ssh_admin_status(host, response.ok)
|
||||
db.commit()
|
||||
return response.model_copy(update={"ssh_admin_ok": host.ssh_admin_ok})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{host_id}/actions/agent-update",
|
||||
response_model=HostRemoteActionStartResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
def update_host_agent_via_ssh(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostRemoteActionStartResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
|
||||
title = "Обновление ssh-monitor (SSH)"
|
||||
try:
|
||||
start_host_remote_action(
|
||||
db,
|
||||
host,
|
||||
title=title,
|
||||
runner=run_ssh_monitor_update_action,
|
||||
poll_remote_log=True,
|
||||
)
|
||||
except RemoteActionAlreadyRunningError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return HostRemoteActionStartResponse(
|
||||
status="running",
|
||||
host_id=host.id,
|
||||
title=title,
|
||||
message="Обновление запущено на сервере SAC и продолжится в фоне",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/request-agent-update", response_model=HostAgentUpdateRequestResponse)
|
||||
def post_request_agent_update(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostAgentUpdateRequestResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
try:
|
||||
request_agent_update(db, host)
|
||||
except AgentUpdateNotAllowedError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
return HostAgentUpdateRequestResponse(
|
||||
status="requested",
|
||||
pending_agent_update=host.pending_agent_update,
|
||||
target_version=host.pending_update_target_version,
|
||||
agent_update_state=host.agent_update_state,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{host_id}/actions/agent-update-fallback",
|
||||
response_model=HostRemoteActionStartResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
def post_agent_update_fallback(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostRemoteActionStartResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
from app.services.ssh_connect import is_linux_host
|
||||
from app.services.winrm_connect import is_windows_host
|
||||
|
||||
product = (host.product or "").strip()
|
||||
if product == PRODUCT_RDP or is_windows_host(host):
|
||||
title = "Обновление через WinRM"
|
||||
elif product == PRODUCT_SSH or is_linux_host(host):
|
||||
title = "Fallback SSH (ssh-monitor)"
|
||||
else:
|
||||
title = "Обновление агента (fallback)"
|
||||
|
||||
try:
|
||||
start_host_remote_action(
|
||||
db,
|
||||
host,
|
||||
title=title,
|
||||
runner=run_agent_fallback_action,
|
||||
)
|
||||
except RemoteActionAlreadyRunningError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return HostRemoteActionStartResponse(
|
||||
status="running",
|
||||
host_id=host.id,
|
||||
title=title,
|
||||
message="Обновление запущено на сервере SAC и продолжится в фоне",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{host_id}/actions/remote-job", response_model=HostRemoteActionJobResponse)
|
||||
def get_host_remote_job(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostRemoteActionJobResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
db.refresh(host)
|
||||
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/remote-job/cancel", response_model=HostRemoteActionJobResponse)
|
||||
def cancel_host_remote_job(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostRemoteActionJobResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
if not cancel_host_remote_action(db, host):
|
||||
raise HTTPException(status_code=409, detail="No running remote action for this host")
|
||||
db.refresh(host)
|
||||
return HostRemoteActionJobResponse(**get_remote_action_status(host))
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/sessions/list", response_model=HostSessionsResponse)
|
||||
def list_host_sessions(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostSessionsResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
from app.services.ssh_connect import is_linux_host
|
||||
from app.services.winrm_connect import is_windows_host
|
||||
|
||||
if is_linux_host(host):
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
try:
|
||||
sessions, result = list_linux_sessions(host, cfg)
|
||||
except SshHostNotLinuxError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SshHostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return HostSessionsResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target or None,
|
||||
sessions=_session_items(sessions),
|
||||
)
|
||||
|
||||
if is_windows_host(host):
|
||||
cfg = get_effective_win_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
try:
|
||||
sessions, result = list_windows_sessions(host, cfg)
|
||||
except HostNotWindowsError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except HostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if result is None:
|
||||
raise HTTPException(status_code=502, detail="WinRM qwinsta failed")
|
||||
return HostSessionsResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target or None,
|
||||
sessions=_session_items(sessions),
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=400, detail="Host OS does not support live sessions")
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/sessions/terminate", response_model=HostSessionTerminateResponse)
|
||||
def terminate_host_session(
|
||||
host_id: int,
|
||||
body: HostSessionTerminateBody,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostSessionTerminateResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
from app.services.ssh_connect import is_linux_host
|
||||
from app.services.winrm_connect import is_windows_host
|
||||
|
||||
sid = body.session_id.strip()
|
||||
if not sid:
|
||||
raise HTTPException(status_code=422, detail="session_id is required")
|
||||
|
||||
if is_linux_host(host):
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||
)
|
||||
try:
|
||||
result = terminate_linux_session(host, cfg, sid)
|
||||
except SshHostNotLinuxError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SshHostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return HostSessionTerminateResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target or None,
|
||||
stdout=result.stdout or None,
|
||||
stderr=result.stderr or None,
|
||||
)
|
||||
|
||||
if is_windows_host(host):
|
||||
cfg = get_effective_win_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
try:
|
||||
result = terminate_windows_session(host, cfg, sid)
|
||||
except HostNotWindowsError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if result is None:
|
||||
raise HTTPException(status_code=502, detail="WinRM logoff failed")
|
||||
return HostSessionTerminateResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target or None,
|
||||
stdout=result.stdout or None,
|
||||
stderr=result.stderr or None,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=400, detail="Host OS does not support session terminate")
|
||||
|
||||
|
||||
@router.patch("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||
def patch_host_agent_config(
|
||||
host_id: int,
|
||||
body: HostAgentConfigUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostAgentConfigResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
try:
|
||||
update_host_agent_config(db, host, body.settings)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
settings = get_host_agent_settings(host)
|
||||
return HostAgentConfigResponse(
|
||||
config_revision=int(host.agent_config_revision or 0),
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{host_id}/access", response_model=HostMgmtAccessResponse)
|
||||
def get_host_access(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostMgmtAccessResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
view = get_host_mgmt_access_view(db, host)
|
||||
return HostMgmtAccessResponse(
|
||||
host_id=view.host_id,
|
||||
has_override=view.has_override,
|
||||
user=view.user,
|
||||
password_set=view.password_set,
|
||||
password_hint=view.password_hint,
|
||||
effective_source=view.effective_source,
|
||||
effective_configured=view.effective_configured,
|
||||
effective_user=view.effective_user,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{host_id}/access", response_model=HostMgmtAccessResponse)
|
||||
def put_host_access(
|
||||
host_id: int,
|
||||
body: HostMgmtAccessUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostMgmtAccessResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
view = upsert_host_mgmt_credentials(
|
||||
db,
|
||||
host,
|
||||
user=body.user,
|
||||
password=body.password,
|
||||
clear=body.clear,
|
||||
)
|
||||
return HostMgmtAccessResponse(
|
||||
host_id=view.host_id,
|
||||
has_override=view.has_override,
|
||||
user=view.user,
|
||||
password_set=view.password_set,
|
||||
password_hint=view.password_hint,
|
||||
effective_source=view.effective_source,
|
||||
effective_configured=view.effective_configured,
|
||||
effective_user=view.effective_user,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||
def get_host_agent_config(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostAgentConfigResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
settings = get_host_agent_settings(host)
|
||||
return HostAgentConfigResponse(
|
||||
config_revision=int(host.agent_config_revision or 0),
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{host_id}", response_model=HostDeleteResponse)
|
||||
def delete_host(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostDeleteResponse:
|
||||
result = delete_host_and_related(db, host_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
db.commit()
|
||||
return HostDeleteResponse(
|
||||
status="deleted",
|
||||
host_id=result.host_id,
|
||||
hostname=result.hostname,
|
||||
deleted_events=result.deleted_events,
|
||||
deleted_problems=result.deleted_problems,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_auth import create_access_token, get_current_user, require_admin, CurrentUser
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.mobile_settings import LOGIN_MODES
|
||||
from app.services.mobile_devices import (
|
||||
get_device_or_raise,
|
||||
list_mobile_devices,
|
||||
revoke_device,
|
||||
delete_device_record,
|
||||
touch_device,
|
||||
update_fcm_token,
|
||||
assert_device_active,
|
||||
)
|
||||
from app.services.mobile_enrollment import (
|
||||
create_enrollment_code,
|
||||
enroll_device,
|
||||
list_enrollment_codes,
|
||||
revoke_enrollment_code,
|
||||
)
|
||||
from app.services.mobile_notify import (
|
||||
MobileNotConfiguredError,
|
||||
MobileSendError,
|
||||
get_effective_fcm_config,
|
||||
send_test_push,
|
||||
)
|
||||
from app.services.mobile_settings import upsert_mobile_settings, get_effective_mobile_settings
|
||||
from app.services.mobile_tokens import find_valid_refresh_token, revoke_refresh_tokens_for_device, store_refresh_token
|
||||
|
||||
router = APIRouter(prefix="/mobile", tags=["mobile"])
|
||||
|
||||
|
||||
class MobileSettingsResponse(BaseModel):
|
||||
devices_allowed: bool
|
||||
max_devices_per_user: int
|
||||
min_app_version: str | None = None
|
||||
fcm_enabled: bool
|
||||
fcm_configured: bool
|
||||
source: str
|
||||
|
||||
|
||||
class MobileSettingsUpdate(BaseModel):
|
||||
devices_allowed: bool
|
||||
max_devices_per_user: int = Field(ge=1, le=50)
|
||||
min_app_version: str | None = None
|
||||
|
||||
|
||||
class EnrollmentCodeCreate(BaseModel):
|
||||
label: str = ""
|
||||
target_user_id: int | None = None
|
||||
login_mode: str = "password"
|
||||
max_uses: int = Field(default=1, ge=1, le=100)
|
||||
expires_in_hours: int = Field(default=24, ge=1, le=720)
|
||||
|
||||
|
||||
class EnrollmentCodeResponse(BaseModel):
|
||||
id: int
|
||||
label: str
|
||||
code_prefix: str
|
||||
target_user_id: int | None
|
||||
target_username: str | None
|
||||
login_mode: str
|
||||
max_uses: int
|
||||
use_count: int
|
||||
expires_at: datetime
|
||||
revoked_at: datetime | None
|
||||
created_at: datetime
|
||||
is_active: bool
|
||||
|
||||
|
||||
class EnrollmentCodeCreatedResponse(EnrollmentCodeResponse):
|
||||
enrollment_code: str = Field(description="Показывается один раз при создании")
|
||||
|
||||
|
||||
class MobileDeviceResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
username: str
|
||||
device_uuid: str
|
||||
display_name: str
|
||||
platform: str
|
||||
app_version: str | None
|
||||
has_fcm_token: bool
|
||||
enrolled_at: datetime
|
||||
last_seen_at: datetime | None
|
||||
revoked_at: datetime | None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class EnrollRequest(BaseModel):
|
||||
enrollment_code: str
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
device_uuid: str
|
||||
display_name: str = "Android"
|
||||
platform: str = "android"
|
||||
app_version: str | None = None
|
||||
fcm_token: str | None = None
|
||||
|
||||
|
||||
class EnrollResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
device_id: int
|
||||
username: str
|
||||
role: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
device_uuid: str
|
||||
|
||||
|
||||
class FcmTokenUpdate(BaseModel):
|
||||
fcm_token: str
|
||||
|
||||
|
||||
class TestPushResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str
|
||||
|
||||
|
||||
def _settings_response(db: Session) -> MobileSettingsResponse:
|
||||
cfg = get_effective_mobile_settings(db)
|
||||
fcm = get_effective_fcm_config()
|
||||
return MobileSettingsResponse(
|
||||
devices_allowed=cfg.devices_allowed,
|
||||
max_devices_per_user=cfg.max_devices_per_user,
|
||||
min_app_version=cfg.min_app_version,
|
||||
fcm_enabled=fcm.enabled,
|
||||
fcm_configured=fcm.configured,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def _code_to_response(item) -> EnrollmentCodeResponse:
|
||||
return EnrollmentCodeResponse(
|
||||
id=item.id,
|
||||
label=item.label,
|
||||
code_prefix=item.code_prefix,
|
||||
target_user_id=item.target_user_id,
|
||||
target_username=item.target_username,
|
||||
login_mode=item.login_mode,
|
||||
max_uses=item.max_uses,
|
||||
use_count=item.use_count,
|
||||
expires_at=item.expires_at,
|
||||
revoked_at=item.revoked_at,
|
||||
created_at=item.created_at,
|
||||
is_active=item.is_active,
|
||||
)
|
||||
|
||||
|
||||
def _device_to_response(item) -> MobileDeviceResponse:
|
||||
return MobileDeviceResponse(
|
||||
id=item.id,
|
||||
user_id=item.user_id,
|
||||
username=item.username,
|
||||
device_uuid=item.device_uuid,
|
||||
display_name=item.display_name,
|
||||
platform=item.platform,
|
||||
app_version=item.app_version,
|
||||
has_fcm_token=item.has_fcm_token,
|
||||
enrolled_at=item.enrolled_at,
|
||||
last_seen_at=item.last_seen_at,
|
||||
revoked_at=item.revoked_at,
|
||||
is_active=item.is_active,
|
||||
)
|
||||
|
||||
|
||||
def _admin_user_id(db: Session, username: str) -> int | None:
|
||||
user = db.scalar(select(User).where(func.lower(User.username) == username.lower()))
|
||||
return user.id if user else None
|
||||
|
||||
|
||||
@router.get("/settings", response_model=MobileSettingsResponse)
|
||||
def get_mobile_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> MobileSettingsResponse:
|
||||
return _settings_response(db)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=MobileSettingsResponse)
|
||||
def update_mobile_settings(
|
||||
body: MobileSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> MobileSettingsResponse:
|
||||
try:
|
||||
upsert_mobile_settings(
|
||||
db,
|
||||
devices_allowed=body.devices_allowed,
|
||||
max_devices_per_user=body.max_devices_per_user,
|
||||
min_app_version=body.min_app_version,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return _settings_response(db)
|
||||
|
||||
|
||||
@router.get("/enrollment-codes", response_model=list[EnrollmentCodeResponse])
|
||||
def get_enrollment_codes(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> list[EnrollmentCodeResponse]:
|
||||
return [_code_to_response(item) for item in list_enrollment_codes(db)]
|
||||
|
||||
|
||||
@router.post("/enrollment-codes", response_model=EnrollmentCodeCreatedResponse)
|
||||
def post_enrollment_code(
|
||||
body: EnrollmentCodeCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_admin),
|
||||
) -> EnrollmentCodeCreatedResponse:
|
||||
if body.login_mode not in LOGIN_MODES:
|
||||
raise HTTPException(status_code=422, detail=f"login_mode must be one of: {sorted(LOGIN_MODES)}")
|
||||
try:
|
||||
summary, plaintext = create_enrollment_code(
|
||||
db,
|
||||
created_by_user_id=_admin_user_id(db, user.username),
|
||||
label=body.label,
|
||||
target_user_id=body.target_user_id,
|
||||
login_mode=body.login_mode,
|
||||
max_uses=body.max_uses,
|
||||
expires_in_hours=body.expires_in_hours,
|
||||
)
|
||||
db.commit()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
base = _code_to_response(summary)
|
||||
return EnrollmentCodeCreatedResponse(**base.model_dump(), enrollment_code=plaintext)
|
||||
|
||||
|
||||
@router.delete("/enrollment-codes/{code_id}", status_code=204)
|
||||
def delete_enrollment_code(
|
||||
code_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> None:
|
||||
try:
|
||||
revoke_enrollment_code(db, code_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/devices", response_model=list[MobileDeviceResponse])
|
||||
def get_devices(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> list[MobileDeviceResponse]:
|
||||
return [_device_to_response(item) for item in list_mobile_devices(db)]
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/revoke", response_model=MobileDeviceResponse)
|
||||
def post_revoke_device(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> MobileDeviceResponse:
|
||||
try:
|
||||
summary = revoke_device(db, device_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return _device_to_response(summary)
|
||||
|
||||
|
||||
@router.delete("/devices/{device_id}", status_code=204)
|
||||
def delete_device(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> None:
|
||||
try:
|
||||
delete_device_record(db, device_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/devices/{device_id}/test-push", response_model=TestPushResponse)
|
||||
def test_device_push(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> TestPushResponse:
|
||||
try:
|
||||
send_test_push(db=db, device_id=device_id)
|
||||
except MobileNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except MobileSendError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return TestPushResponse(message="Тестовое push отправлено на устройство")
|
||||
|
||||
|
||||
@router.post("/enroll", response_model=EnrollResponse)
|
||||
def post_enroll(body: EnrollRequest, db: Session = Depends(get_db)) -> EnrollResponse:
|
||||
try:
|
||||
result = enroll_device(
|
||||
db,
|
||||
enrollment_code=body.enrollment_code,
|
||||
username=body.username,
|
||||
password=body.password,
|
||||
device_uuid=body.device_uuid,
|
||||
display_name=body.display_name,
|
||||
platform=body.platform,
|
||||
app_version=body.app_version,
|
||||
fcm_token=body.fcm_token,
|
||||
create_access_token=create_access_token,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return EnrollResponse(
|
||||
access_token=result.access_token,
|
||||
refresh_token=result.refresh_token,
|
||||
device_id=result.device_id,
|
||||
username=result.username,
|
||||
role=result.role,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/refresh", response_model=EnrollResponse)
|
||||
def post_refresh(body: RefreshRequest, db: Session = Depends(get_db)) -> EnrollResponse:
|
||||
token_row = find_valid_refresh_token(db, body.refresh_token.strip())
|
||||
if token_row is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
device = get_device_or_raise(db, token_row.device_id)
|
||||
try:
|
||||
assert_device_active(device)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||
if device.device_uuid != body.device_uuid.strip():
|
||||
raise HTTPException(status_code=401, detail="device_uuid mismatch")
|
||||
|
||||
user = db.get(User, device.user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="User inactive")
|
||||
|
||||
revoke_refresh_tokens_for_device(db, device.id)
|
||||
refresh_plain = store_refresh_token(db, device_id=device.id)
|
||||
touch_device(db, device)
|
||||
access_token = create_access_token(user.username, user.role, device_id=device.id)
|
||||
db.commit()
|
||||
|
||||
return EnrollResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_plain,
|
||||
device_id=device.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/devices/me/fcm", status_code=204)
|
||||
def put_my_fcm_token(
|
||||
body: FcmTokenUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
if user.device_id is None:
|
||||
raise HTTPException(status_code=403, detail="Mobile device token required")
|
||||
device = get_device_or_raise(db, user.device_id)
|
||||
try:
|
||||
assert_device_active(device)
|
||||
update_fcm_token(db, device, body.fcm_token)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,376 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
|
||||
|
||||
from app.auth.jwt_auth import get_current_user
|
||||
|
||||
from app.database import get_db
|
||||
|
||||
from app.models import Event, Host, Problem, ProblemEvent
|
||||
from app.services.event_type_visibility import get_hidden_event_types
|
||||
from app.utils.sql_like import escape_ilike_pattern
|
||||
|
||||
|
||||
|
||||
router = APIRouter(prefix="/problems", tags=["problems"])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemSummary(BaseModel):
|
||||
|
||||
id: int
|
||||
|
||||
host_id: int | None
|
||||
|
||||
hostname: str | None
|
||||
|
||||
display_name: str | None = None
|
||||
|
||||
title: str
|
||||
|
||||
summary: str
|
||||
|
||||
severity: str
|
||||
|
||||
status: str
|
||||
|
||||
rule_id: str | None
|
||||
|
||||
fingerprint: str
|
||||
|
||||
event_count: int
|
||||
|
||||
last_seen_at: datetime
|
||||
|
||||
created_at: datetime
|
||||
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemListResponse(BaseModel):
|
||||
|
||||
items: list[ProblemSummary]
|
||||
|
||||
total: int
|
||||
|
||||
page: int
|
||||
|
||||
page_size: int
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemEventItem(BaseModel):
|
||||
|
||||
id: int
|
||||
|
||||
event_id: str
|
||||
|
||||
occurred_at: datetime
|
||||
|
||||
type: str
|
||||
|
||||
severity: str
|
||||
|
||||
title: str
|
||||
|
||||
summary: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ProblemDetail(ProblemSummary):
|
||||
|
||||
events: list[ProblemEventItem]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _problem_summary(p: Problem) -> ProblemSummary:
|
||||
|
||||
return ProblemSummary(
|
||||
|
||||
id=p.id,
|
||||
|
||||
host_id=p.host_id,
|
||||
|
||||
hostname=p.host.hostname if p.host else None,
|
||||
|
||||
display_name=p.host.display_name if p.host else None,
|
||||
|
||||
title=p.title,
|
||||
|
||||
summary=p.summary,
|
||||
|
||||
severity=p.severity,
|
||||
|
||||
status=p.status,
|
||||
|
||||
rule_id=p.rule_id,
|
||||
|
||||
fingerprint=p.fingerprint,
|
||||
|
||||
event_count=p.event_count,
|
||||
|
||||
last_seen_at=p.last_seen_at,
|
||||
|
||||
created_at=p.created_at,
|
||||
|
||||
updated_at=p.updated_at,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("", response_model=ProblemListResponse)
|
||||
|
||||
def list_problems(
|
||||
|
||||
page: int = Query(1, ge=1),
|
||||
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
|
||||
status: str | None = Query(None),
|
||||
|
||||
severity: str | None = Query(None),
|
||||
|
||||
host_id: int | None = Query(None),
|
||||
|
||||
hostname: str | None = Query(None),
|
||||
|
||||
created_within_hours: int | None = Query(None, ge=1, le=8760),
|
||||
|
||||
resolved_within_hours: int | None = Query(None, ge=1, le=8760),
|
||||
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
_user: str = Depends(get_current_user),
|
||||
|
||||
) -> ProblemListResponse:
|
||||
|
||||
stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host))
|
||||
|
||||
count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True)
|
||||
|
||||
|
||||
|
||||
if status:
|
||||
|
||||
stmt = stmt.where(Problem.status == status)
|
||||
|
||||
count_stmt = count_stmt.where(Problem.status == status)
|
||||
|
||||
if severity:
|
||||
|
||||
stmt = stmt.where(Problem.severity == severity)
|
||||
|
||||
count_stmt = count_stmt.where(Problem.severity == severity)
|
||||
|
||||
if host_id is not None:
|
||||
|
||||
stmt = stmt.where(Problem.host_id == host_id)
|
||||
|
||||
count_stmt = count_stmt.where(Problem.host_id == host_id)
|
||||
|
||||
if hostname:
|
||||
|
||||
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||
|
||||
stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
|
||||
count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\"))
|
||||
|
||||
if created_within_hours is not None:
|
||||
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=created_within_hours)
|
||||
|
||||
stmt = stmt.where(Problem.created_at >= since, Problem.status != "resolved")
|
||||
|
||||
count_stmt = count_stmt.where(Problem.created_at >= since, Problem.status != "resolved")
|
||||
|
||||
if resolved_within_hours is not None:
|
||||
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=resolved_within_hours)
|
||||
|
||||
stmt = stmt.where(Problem.status == "resolved", Problem.updated_at >= since)
|
||||
|
||||
count_stmt = count_stmt.where(Problem.status == "resolved", Problem.updated_at >= since)
|
||||
|
||||
|
||||
|
||||
total = db.scalar(count_stmt) or 0
|
||||
|
||||
rows = db.scalars(
|
||||
|
||||
stmt.order_by(Problem.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
).all()
|
||||
|
||||
|
||||
|
||||
items = [_problem_summary(p) for p in rows]
|
||||
|
||||
return ProblemListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/{problem_id}", response_model=ProblemDetail)
|
||||
|
||||
def get_problem(
|
||||
|
||||
problem_id: int,
|
||||
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
_user: str = Depends(get_current_user),
|
||||
|
||||
) -> ProblemDetail:
|
||||
|
||||
problem = db.scalar(
|
||||
|
||||
select(Problem).where(Problem.id == problem_id).options(joinedload(Problem.host))
|
||||
|
||||
)
|
||||
|
||||
if problem is None:
|
||||
|
||||
raise HTTPException(status_code=404, detail="Problem not found")
|
||||
|
||||
|
||||
|
||||
event_rows = db.scalars(
|
||||
|
||||
select(Event)
|
||||
|
||||
.join(ProblemEvent, ProblemEvent.event_id == Event.id)
|
||||
|
||||
.where(ProblemEvent.problem_id == problem_id)
|
||||
|
||||
.order_by(Event.occurred_at.desc())
|
||||
|
||||
).all()
|
||||
hidden = get_hidden_event_types(db)
|
||||
event_rows = [event for event in event_rows if event.type not in hidden]
|
||||
|
||||
|
||||
|
||||
base = _problem_summary(problem)
|
||||
|
||||
return ProblemDetail(
|
||||
|
||||
**base.model_dump(),
|
||||
|
||||
events=[
|
||||
|
||||
ProblemEventItem(
|
||||
|
||||
id=e.id,
|
||||
|
||||
event_id=e.event_id,
|
||||
|
||||
occurred_at=e.occurred_at,
|
||||
|
||||
type=e.type,
|
||||
|
||||
severity=e.severity,
|
||||
|
||||
title=e.title,
|
||||
|
||||
summary=e.summary,
|
||||
|
||||
)
|
||||
|
||||
for e in event_rows
|
||||
|
||||
],
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{problem_id}/ack")
|
||||
|
||||
def ack_problem(
|
||||
|
||||
problem_id: int,
|
||||
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
_user: str = Depends(get_current_user),
|
||||
|
||||
) -> dict:
|
||||
|
||||
problem = db.get(Problem, problem_id)
|
||||
|
||||
if problem is None:
|
||||
|
||||
raise HTTPException(status_code=404, detail="Problem not found")
|
||||
|
||||
if problem.status == "resolved":
|
||||
|
||||
raise HTTPException(status_code=409, detail="Problem already resolved")
|
||||
|
||||
problem.status = "acknowledged"
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"id": problem.id, "status": problem.status}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/{problem_id}/resolve")
|
||||
|
||||
def resolve_problem(
|
||||
|
||||
problem_id: int,
|
||||
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
_user: str = Depends(get_current_user),
|
||||
|
||||
) -> dict:
|
||||
|
||||
problem = db.get(Problem, problem_id)
|
||||
|
||||
if problem is None:
|
||||
|
||||
raise HTTPException(status_code=404, detail="Problem not found")
|
||||
|
||||
if problem.status == "resolved":
|
||||
|
||||
raise HTTPException(status_code=409, detail="Problem already resolved")
|
||||
|
||||
problem.status = "resolved"
|
||||
problem.resolved_by = "manual"
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"id": problem.id, "status": problem.status}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import agent, auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(agent.router)
|
||||
api_router.include_router(events.router)
|
||||
api_router.include_router(hosts.router)
|
||||
api_router.include_router(problems.router)
|
||||
api_router.include_router(dashboards.router)
|
||||
api_router.include_router(settings.router)
|
||||
api_router.include_router(system.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(stream.router)
|
||||
api_router.include_router(mobile.router)
|
||||
@@ -0,0 +1,860 @@
|
||||
from dataclasses import replace
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_auth import require_admin
|
||||
from app.database import get_db
|
||||
from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message
|
||||
from app.services.notification_policy import (
|
||||
NotificationPolicyConfig,
|
||||
get_effective_notification_policy,
|
||||
upsert_notification_policy,
|
||||
)
|
||||
from app.services.notification_settings import (
|
||||
VALID_SEVERITIES,
|
||||
EmailConfig,
|
||||
TelegramConfig,
|
||||
WebhookConfig,
|
||||
get_effective_email_config,
|
||||
get_effective_telegram_config,
|
||||
get_effective_webhook_config,
|
||||
upsert_email_channel,
|
||||
upsert_telegram_channel,
|
||||
upsert_webhook_channel,
|
||||
)
|
||||
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
||||
from app.services.webhook_notify import (
|
||||
WebhookNotConfiguredError,
|
||||
WebhookSendError,
|
||||
send_webhook_test_message,
|
||||
)
|
||||
from app.services.event_severity_overrides import (
|
||||
SOURCE_DB,
|
||||
list_severity_override_items,
|
||||
replace_severity_overrides,
|
||||
)
|
||||
from app.services.event_type_visibility import replace_event_visibility
|
||||
from app.services.ui_settings import (
|
||||
get_effective_ui_settings,
|
||||
upsert_ui_settings,
|
||||
)
|
||||
from app.services.linux_admin_settings import (
|
||||
get_effective_linux_admin_config,
|
||||
upsert_linux_admin_settings,
|
||||
)
|
||||
from app.services.win_admin_settings import (
|
||||
get_effective_win_admin_config,
|
||||
upsert_win_admin_settings,
|
||||
)
|
||||
from app.services.rdp_flap_settings import (
|
||||
get_effective_rdp_flap_settings,
|
||||
upsert_rdp_flap_settings,
|
||||
)
|
||||
from app.services.agent_git_release import get_git_release_versions
|
||||
from app.services.agent_update_settings import (
|
||||
get_effective_agent_update_config,
|
||||
upsert_agent_update_settings,
|
||||
)
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError,
|
||||
WinAdminNotConfiguredError,
|
||||
resolve_windows_host_target,
|
||||
test_winrm_connection,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= visible_tail:
|
||||
return "*" * len(text)
|
||||
return f"{'*' * 8}…{text[-visible_tail:]}"
|
||||
|
||||
|
||||
def _mask_url(value: str) -> str | None:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= 20:
|
||||
return f"{'*' * 6}…{text[-4:]}"
|
||||
return f"{text[:16]}…{text[-6:]}"
|
||||
|
||||
|
||||
class TelegramSettingsResponse(BaseModel):
|
||||
enabled: bool
|
||||
configured: bool
|
||||
chat_id_hint: str | None = None
|
||||
bot_token_hint: str | None = None
|
||||
min_severity: str
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class WebhookSettingsResponse(BaseModel):
|
||||
enabled: bool
|
||||
configured: bool
|
||||
url_hint: str | None = None
|
||||
secret_header: str | None = None
|
||||
secret_hint: str | None = None
|
||||
min_severity: str
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class EmailSettingsResponse(BaseModel):
|
||||
enabled: bool
|
||||
configured: bool
|
||||
smtp_host_hint: str | None = None
|
||||
smtp_port: int
|
||||
smtp_user: str | None = None
|
||||
smtp_password_hint: str | None = None
|
||||
mail_from: str | None = None
|
||||
mail_to_hint: str | None = None
|
||||
smtp_starttls: bool
|
||||
smtp_ssl: bool
|
||||
min_severity: str
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class NotificationPolicyResponse(BaseModel):
|
||||
min_severity: str
|
||||
use_telegram: bool
|
||||
use_webhook: bool
|
||||
use_email: bool
|
||||
use_mobile: bool
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class NotificationSettingsResponse(BaseModel):
|
||||
policy: NotificationPolicyResponse
|
||||
telegram: TelegramSettingsResponse
|
||||
webhook: WebhookSettingsResponse
|
||||
email: EmailSettingsResponse
|
||||
|
||||
|
||||
class NotificationPolicyUpdate(BaseModel):
|
||||
min_severity: str
|
||||
use_telegram: bool
|
||||
use_webhook: bool
|
||||
use_email: bool
|
||||
use_mobile: bool = False
|
||||
|
||||
|
||||
class TelegramSettingsUpdate(BaseModel):
|
||||
enabled: bool
|
||||
bot_token: str | None = None
|
||||
chat_id: str | None = None
|
||||
|
||||
|
||||
class WebhookSettingsUpdate(BaseModel):
|
||||
enabled: bool
|
||||
url: str | None = None
|
||||
secret_header: str | None = None
|
||||
secret: str | None = None
|
||||
|
||||
|
||||
class EmailSettingsUpdate(BaseModel):
|
||||
enabled: bool
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int | None = None
|
||||
smtp_user: str | None = None
|
||||
smtp_password: str | None = None
|
||||
mail_from: str | None = None
|
||||
mail_to: str | None = None
|
||||
smtp_starttls: bool | None = None
|
||||
smtp_ssl: bool | None = None
|
||||
|
||||
|
||||
class ChannelTestResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str
|
||||
|
||||
|
||||
def _policy_to_response(cfg: NotificationPolicyConfig) -> NotificationPolicyResponse:
|
||||
return NotificationPolicyResponse(
|
||||
min_severity=cfg.min_severity,
|
||||
use_telegram=cfg.use_telegram,
|
||||
use_webhook=cfg.use_webhook,
|
||||
use_email=cfg.use_email,
|
||||
use_mobile=cfg.use_mobile,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def _telegram_to_response(cfg: TelegramConfig, policy: NotificationPolicyConfig) -> TelegramSettingsResponse:
|
||||
return TelegramSettingsResponse(
|
||||
enabled=cfg.enabled,
|
||||
configured=cfg.configured,
|
||||
chat_id_hint=_mask_secret(cfg.chat_id),
|
||||
bot_token_hint=_mask_secret(cfg.bot_token),
|
||||
min_severity=policy.min_severity,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def _webhook_to_response(cfg: WebhookConfig, policy: NotificationPolicyConfig) -> WebhookSettingsResponse:
|
||||
return WebhookSettingsResponse(
|
||||
enabled=cfg.enabled,
|
||||
configured=cfg.configured,
|
||||
url_hint=_mask_url(cfg.url),
|
||||
secret_header=cfg.secret_header or None,
|
||||
secret_hint=_mask_secret(cfg.secret),
|
||||
min_severity=policy.min_severity,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def _email_to_response(cfg: EmailConfig, policy: NotificationPolicyConfig) -> EmailSettingsResponse:
|
||||
return EmailSettingsResponse(
|
||||
enabled=cfg.enabled,
|
||||
configured=cfg.configured,
|
||||
smtp_host_hint=_mask_url(cfg.smtp_host) if cfg.smtp_host else None,
|
||||
smtp_port=cfg.smtp_port,
|
||||
smtp_user=cfg.smtp_user or None,
|
||||
smtp_password_hint=_mask_secret(cfg.smtp_password),
|
||||
mail_from=cfg.mail_from or None,
|
||||
mail_to_hint=_mask_secret(cfg.mail_to.replace(";", ",")) if cfg.mail_to else None,
|
||||
smtp_starttls=cfg.smtp_starttls,
|
||||
smtp_ssl=cfg.smtp_ssl,
|
||||
min_severity=policy.min_severity,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=NotificationSettingsResponse)
|
||||
def get_notification_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> NotificationSettingsResponse:
|
||||
policy = get_effective_notification_policy(db)
|
||||
return NotificationSettingsResponse(
|
||||
policy=_policy_to_response(policy),
|
||||
telegram=_telegram_to_response(get_effective_telegram_config(db), policy),
|
||||
webhook=_webhook_to_response(get_effective_webhook_config(db), policy),
|
||||
email=_email_to_response(get_effective_email_config(db), policy),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/notifications/policy", response_model=NotificationPolicyResponse)
|
||||
def update_notification_policy(
|
||||
body: NotificationPolicyUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> NotificationPolicyResponse:
|
||||
if body.min_severity not in VALID_SEVERITIES:
|
||||
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
|
||||
if not any((body.use_telegram, body.use_webhook, body.use_email, body.use_mobile)):
|
||||
raise HTTPException(status_code=422, detail="Выберите хотя бы один канал")
|
||||
try:
|
||||
policy = upsert_notification_policy(
|
||||
db,
|
||||
min_severity=body.min_severity,
|
||||
use_telegram=body.use_telegram,
|
||||
use_webhook=body.use_webhook,
|
||||
use_email=body.use_email,
|
||||
use_mobile=body.use_mobile,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return _policy_to_response(policy)
|
||||
|
||||
|
||||
@router.put("/notifications/telegram", response_model=TelegramSettingsResponse)
|
||||
def update_telegram_settings(
|
||||
body: TelegramSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> TelegramSettingsResponse:
|
||||
try:
|
||||
cfg = upsert_telegram_channel(
|
||||
db,
|
||||
enabled=body.enabled,
|
||||
bot_token=body.bot_token,
|
||||
chat_id=body.chat_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
policy = get_effective_notification_policy(db)
|
||||
return _telegram_to_response(cfg, policy)
|
||||
|
||||
|
||||
@router.put("/notifications/webhook", response_model=WebhookSettingsResponse)
|
||||
def update_webhook_settings(
|
||||
body: WebhookSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> WebhookSettingsResponse:
|
||||
try:
|
||||
cfg = upsert_webhook_channel(
|
||||
db,
|
||||
enabled=body.enabled,
|
||||
url=body.url,
|
||||
secret_header=body.secret_header,
|
||||
secret=body.secret,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
policy = get_effective_notification_policy(db)
|
||||
return _webhook_to_response(cfg, policy)
|
||||
|
||||
|
||||
@router.put("/notifications/email", response_model=EmailSettingsResponse)
|
||||
def update_email_settings(
|
||||
body: EmailSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> EmailSettingsResponse:
|
||||
try:
|
||||
cfg = upsert_email_channel(
|
||||
db,
|
||||
enabled=body.enabled,
|
||||
smtp_host=body.smtp_host,
|
||||
smtp_port=body.smtp_port,
|
||||
smtp_user=body.smtp_user,
|
||||
smtp_password=body.smtp_password,
|
||||
mail_from=body.mail_from,
|
||||
mail_to=body.mail_to,
|
||||
smtp_starttls=body.smtp_starttls,
|
||||
smtp_ssl=body.smtp_ssl,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
policy = get_effective_notification_policy(db)
|
||||
return _email_to_response(cfg, policy)
|
||||
|
||||
|
||||
@router.post("/notifications/telegram/test", response_model=ChannelTestResponse)
|
||||
def test_telegram_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> ChannelTestResponse:
|
||||
cfg = get_effective_telegram_config(db)
|
||||
try:
|
||||
send_telegram_test_message(config=cfg)
|
||||
except TelegramNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except TelegramSendError as exc:
|
||||
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
|
||||
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
||||
return ChannelTestResponse(message="Тестовое сообщение отправлено в Telegram")
|
||||
|
||||
|
||||
@router.post("/notifications/webhook/test", response_model=ChannelTestResponse)
|
||||
def test_webhook_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> ChannelTestResponse:
|
||||
cfg = get_effective_webhook_config(db)
|
||||
try:
|
||||
send_webhook_test_message(config=cfg)
|
||||
except WebhookNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except WebhookSendError as exc:
|
||||
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
|
||||
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
||||
return ChannelTestResponse(message="Тестовый POST отправлен на webhook URL")
|
||||
|
||||
|
||||
@router.post("/notifications/email/test", response_model=ChannelTestResponse)
|
||||
def test_email_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> ChannelTestResponse:
|
||||
cfg = get_effective_email_config(db)
|
||||
try:
|
||||
send_email_test_message(config=cfg)
|
||||
except EmailNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except EmailSendError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP")
|
||||
|
||||
|
||||
class EventSeverityOverrideRow(BaseModel):
|
||||
event_type: str
|
||||
default_severity: str
|
||||
override_severity: str | None = None
|
||||
show_in_events: bool = True
|
||||
|
||||
|
||||
class EventSeverityOverridesResponse(BaseModel):
|
||||
items: list[EventSeverityOverrideRow]
|
||||
source: str = Field(description="db — overrides хранятся в PostgreSQL")
|
||||
|
||||
|
||||
class EventSeverityOverridesUpdate(BaseModel):
|
||||
overrides: dict[str, str | None] = Field(
|
||||
default_factory=dict,
|
||||
description="event_type → severity; null или пустая строка сбрасывает override",
|
||||
)
|
||||
visibility: dict[str, bool | None] = Field(
|
||||
default_factory=dict,
|
||||
description="event_type → show_in_events; null или true сбрасывает скрытие (показывать)",
|
||||
)
|
||||
|
||||
|
||||
def _severity_override_rows(items) -> list[EventSeverityOverrideRow]:
|
||||
return [
|
||||
EventSeverityOverrideRow(
|
||||
event_type=i.event_type,
|
||||
default_severity=i.default_severity,
|
||||
override_severity=i.override_severity,
|
||||
show_in_events=i.show_in_events,
|
||||
)
|
||||
for i in items
|
||||
]
|
||||
|
||||
|
||||
@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
|
||||
def get_severity_overrides(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> EventSeverityOverridesResponse:
|
||||
items = list_severity_override_items(db)
|
||||
return EventSeverityOverridesResponse(
|
||||
items=_severity_override_rows(items),
|
||||
source=SOURCE_DB,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
|
||||
def update_severity_overrides(
|
||||
body: EventSeverityOverridesUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> EventSeverityOverridesResponse:
|
||||
try:
|
||||
items = list_severity_override_items(db)
|
||||
if body.overrides:
|
||||
items = replace_severity_overrides(db, body.overrides)
|
||||
if body.visibility:
|
||||
replace_event_visibility(db, body.visibility)
|
||||
items = list_severity_override_items(db)
|
||||
if not body.overrides and not body.visibility:
|
||||
raise HTTPException(status_code=422, detail="Нет изменений для сохранения")
|
||||
db.commit()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return EventSeverityOverridesResponse(
|
||||
items=_severity_override_rows(items),
|
||||
source=SOURCE_DB,
|
||||
)
|
||||
|
||||
|
||||
class UiSettingsResponse(BaseModel):
|
||||
show_sidebar_system_stats: bool
|
||||
source: str = Field(description="db или default")
|
||||
|
||||
|
||||
class UiSettingsUpdate(BaseModel):
|
||||
show_sidebar_system_stats: bool
|
||||
|
||||
|
||||
@router.get("/ui", response_model=UiSettingsResponse)
|
||||
def get_ui_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> UiSettingsResponse:
|
||||
cfg = get_effective_ui_settings(db)
|
||||
return UiSettingsResponse(
|
||||
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/ui", response_model=UiSettingsResponse)
|
||||
def update_ui_settings(
|
||||
body: UiSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> UiSettingsResponse:
|
||||
cfg = upsert_ui_settings(db, show_sidebar_system_stats=body.show_sidebar_system_stats)
|
||||
return UiSettingsResponse(
|
||||
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
class WinAdminSettingsResponse(BaseModel):
|
||||
configured: bool
|
||||
user: str | None = None
|
||||
password_hint: str | None = None
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class WinAdminSettingsUpdate(BaseModel):
|
||||
user: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
@router.get("/win-admin", response_model=WinAdminSettingsResponse)
|
||||
def get_win_admin_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> WinAdminSettingsResponse:
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
return WinAdminSettingsResponse(
|
||||
configured=cfg.configured,
|
||||
user=cfg.user or None,
|
||||
password_hint=_mask_secret(cfg.password),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/win-admin", response_model=WinAdminSettingsResponse)
|
||||
def update_win_admin_settings(
|
||||
body: WinAdminSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> WinAdminSettingsResponse:
|
||||
if body.user is not None and not body.user.strip():
|
||||
raise HTTPException(status_code=422, detail="user must not be empty")
|
||||
cfg = upsert_win_admin_settings(db, user=body.user, password=body.password)
|
||||
return WinAdminSettingsResponse(
|
||||
configured=cfg.configured,
|
||||
user=cfg.user or None,
|
||||
password_hint=_mask_secret(cfg.password),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
class RdpFlapSettingsResponse(BaseModel):
|
||||
auto_disconnect: bool
|
||||
win_admin_configured: bool
|
||||
source: str = Field(description="db или default")
|
||||
|
||||
|
||||
class RdpFlapSettingsUpdate(BaseModel):
|
||||
auto_disconnect: bool
|
||||
|
||||
|
||||
@router.get("/rdp-flap", response_model=RdpFlapSettingsResponse)
|
||||
def get_rdp_flap_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> RdpFlapSettingsResponse:
|
||||
cfg = get_effective_rdp_flap_settings(db)
|
||||
win_cfg = get_effective_win_admin_config(db)
|
||||
return RdpFlapSettingsResponse(
|
||||
auto_disconnect=cfg.auto_disconnect,
|
||||
win_admin_configured=win_cfg.configured,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/rdp-flap", response_model=RdpFlapSettingsResponse)
|
||||
def update_rdp_flap_settings(
|
||||
body: RdpFlapSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> RdpFlapSettingsResponse:
|
||||
cfg = upsert_rdp_flap_settings(db, auto_disconnect=body.auto_disconnect)
|
||||
win_cfg = get_effective_win_admin_config(db)
|
||||
return RdpFlapSettingsResponse(
|
||||
auto_disconnect=cfg.auto_disconnect,
|
||||
win_admin_configured=win_cfg.configured,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
class LinuxAdminSettingsResponse(BaseModel):
|
||||
configured: bool
|
||||
user: str | None = None
|
||||
password_hint: str | None = None
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class LinuxAdminSettingsUpdate(BaseModel):
|
||||
user: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
@router.get("/linux-admin", response_model=LinuxAdminSettingsResponse)
|
||||
def get_linux_admin_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> LinuxAdminSettingsResponse:
|
||||
cfg = get_effective_linux_admin_config(db)
|
||||
return LinuxAdminSettingsResponse(
|
||||
configured=cfg.configured,
|
||||
user=cfg.user or None,
|
||||
password_hint=_mask_secret(cfg.password),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/linux-admin", response_model=LinuxAdminSettingsResponse)
|
||||
def update_linux_admin_settings(
|
||||
body: LinuxAdminSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> LinuxAdminSettingsResponse:
|
||||
if body.user is not None and not body.user.strip():
|
||||
raise HTTPException(status_code=422, detail="user must not be empty")
|
||||
cfg = upsert_linux_admin_settings(db, user=body.user, password=body.password)
|
||||
return LinuxAdminSettingsResponse(
|
||||
configured=cfg.configured,
|
||||
user=cfg.user or None,
|
||||
password_hint=_mask_secret(cfg.password),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
class AgentUpdateSettingsResponse(BaseModel):
|
||||
mode: str
|
||||
fallback_enabled: bool
|
||||
fallback_after_minutes: int
|
||||
recommended_rdp_version: str | None = None
|
||||
recommended_ssh_version: str | None = None
|
||||
min_rdp_version: str | None = None
|
||||
min_ssh_version: str | None = None
|
||||
win_agent_update_script: str | None = None
|
||||
rdp_git_repo_url: str | None = None
|
||||
ssh_git_repo_url: str | None = None
|
||||
git_branch: str = "main"
|
||||
git_latest_rdp_version: str | None = None
|
||||
git_latest_ssh_version: str | None = None
|
||||
git_fetched_at: datetime | None = None
|
||||
git_errors: dict[str, str] = Field(default_factory=dict)
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class AgentUpdateSettingsUpdate(BaseModel):
|
||||
mode: str | None = None
|
||||
fallback_enabled: bool | None = None
|
||||
fallback_after_minutes: int | None = None
|
||||
recommended_rdp_version: str | None = None
|
||||
recommended_ssh_version: str | None = None
|
||||
min_rdp_version: str | None = None
|
||||
min_ssh_version: str | None = None
|
||||
win_agent_update_script: str | None = None
|
||||
rdp_git_repo_url: str | None = None
|
||||
ssh_git_repo_url: str | None = None
|
||||
git_branch: str | None = None
|
||||
|
||||
|
||||
class AgentGitTestRequest(BaseModel):
|
||||
rdp_git_repo_url: str | None = None
|
||||
ssh_git_repo_url: str | None = None
|
||||
git_branch: str | None = None
|
||||
|
||||
|
||||
class AgentGitTestResponse(BaseModel):
|
||||
git_latest_rdp_version: str | None = None
|
||||
git_latest_ssh_version: str | None = None
|
||||
git_fetched_at: datetime | None = None
|
||||
git_errors: dict[str, str] = Field(default_factory=dict)
|
||||
from_cache: bool = False
|
||||
|
||||
|
||||
def _agent_update_to_response(cfg, git_release=None) -> AgentUpdateSettingsResponse:
|
||||
git_latest_rdp = None
|
||||
git_latest_ssh = None
|
||||
git_fetched_at = None
|
||||
git_errors: dict[str, str] = {}
|
||||
if git_release is not None:
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
|
||||
git_latest_rdp = git_release.versions.get(PRODUCT_RDP) or None
|
||||
git_latest_ssh = git_release.versions.get(PRODUCT_SSH) or None
|
||||
git_fetched_at = git_release.fetched_at
|
||||
git_errors = dict(git_release.errors)
|
||||
return AgentUpdateSettingsResponse(
|
||||
mode=cfg.mode,
|
||||
fallback_enabled=cfg.fallback_enabled,
|
||||
fallback_after_minutes=cfg.fallback_after_minutes,
|
||||
recommended_rdp_version=cfg.recommended_rdp_version or None,
|
||||
recommended_ssh_version=cfg.recommended_ssh_version or None,
|
||||
min_rdp_version=cfg.min_rdp_version or None,
|
||||
min_ssh_version=cfg.min_ssh_version or None,
|
||||
win_agent_update_script=cfg.win_agent_update_script or None,
|
||||
rdp_git_repo_url=cfg.rdp_git_repo_url or None,
|
||||
ssh_git_repo_url=cfg.ssh_git_repo_url or None,
|
||||
git_branch=cfg.git_branch or "main",
|
||||
git_latest_rdp_version=git_latest_rdp,
|
||||
git_latest_ssh_version=git_latest_ssh,
|
||||
git_fetched_at=git_fetched_at,
|
||||
git_errors=git_errors,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agent-updates", response_model=AgentUpdateSettingsResponse)
|
||||
def get_agent_update_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> AgentUpdateSettingsResponse:
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
git_release = get_git_release_versions(cfg, db=db)
|
||||
return _agent_update_to_response(cfg, git_release)
|
||||
|
||||
|
||||
@router.put("/agent-updates", response_model=AgentUpdateSettingsResponse)
|
||||
def update_agent_update_settings(
|
||||
body: AgentUpdateSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> AgentUpdateSettingsResponse:
|
||||
try:
|
||||
cfg = upsert_agent_update_settings(
|
||||
db,
|
||||
mode=body.mode,
|
||||
fallback_enabled=body.fallback_enabled,
|
||||
fallback_after_minutes=body.fallback_after_minutes,
|
||||
recommended_rdp_version=body.recommended_rdp_version,
|
||||
recommended_ssh_version=body.recommended_ssh_version,
|
||||
min_rdp_version=body.min_rdp_version,
|
||||
min_ssh_version=body.min_ssh_version,
|
||||
win_agent_update_script=body.win_agent_update_script,
|
||||
rdp_git_repo_url=body.rdp_git_repo_url,
|
||||
ssh_git_repo_url=body.ssh_git_repo_url,
|
||||
git_branch=body.git_branch,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
|
||||
return _agent_update_to_response(cfg, git_release)
|
||||
|
||||
|
||||
@router.post("/agent-updates/test-git", response_model=AgentGitTestResponse)
|
||||
def test_agent_git_release(
|
||||
body: AgentGitTestRequest | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> AgentGitTestResponse:
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
if body is not None:
|
||||
cfg = replace(
|
||||
cfg,
|
||||
rdp_git_repo_url=(body.rdp_git_repo_url or cfg.rdp_git_repo_url).strip(),
|
||||
ssh_git_repo_url=(body.ssh_git_repo_url or cfg.ssh_git_repo_url).strip(),
|
||||
git_branch=(body.git_branch or cfg.git_branch or "main").strip() or "main",
|
||||
)
|
||||
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
|
||||
return AgentGitTestResponse(
|
||||
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
|
||||
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
|
||||
git_fetched_at=git_release.fetched_at,
|
||||
git_errors=dict(git_release.errors),
|
||||
from_cache=git_release.from_cache,
|
||||
)
|
||||
|
||||
|
||||
class LoginSecuritySettingsResponse(BaseModel):
|
||||
ip_whitelist: list[str] = Field(default_factory=list)
|
||||
ip_whitelist_text: str = ""
|
||||
sync_fail2ban: bool = False
|
||||
max_failures: int = 3
|
||||
window_minutes: int = 15
|
||||
fail2ban_available: bool = False
|
||||
source: str = "default"
|
||||
|
||||
|
||||
class LoginSecuritySettingsUpdate(BaseModel):
|
||||
ip_whitelist_text: str = ""
|
||||
sync_fail2ban: bool | None = None
|
||||
|
||||
|
||||
class LoginBlockItem(BaseModel):
|
||||
ip_address: str
|
||||
scope: str
|
||||
failure_count: int | None = None
|
||||
usernames: list[str] = Field(default_factory=list)
|
||||
blocked_until: datetime | None = None
|
||||
reason: str
|
||||
|
||||
|
||||
class LoginUnblockRequest(BaseModel):
|
||||
ip_address: str
|
||||
scope: str = Field(default="both", description="web | ssh | both")
|
||||
|
||||
|
||||
class LoginUnblockResponse(BaseModel):
|
||||
messages: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _login_security_response(db: Session) -> LoginSecuritySettingsResponse:
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
from app.services.fail2ban_sync import fail2ban_available
|
||||
from app.services.login_security_settings import get_effective_login_security_config
|
||||
|
||||
cfg = get_effective_login_security_config(db)
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
text = (row.login_ip_whitelist or "") if row is not None else ""
|
||||
return LoginSecuritySettingsResponse(
|
||||
ip_whitelist=list(cfg.ip_whitelist),
|
||||
ip_whitelist_text=text,
|
||||
sync_fail2ban=cfg.sync_fail2ban,
|
||||
max_failures=cfg.max_failures,
|
||||
window_minutes=cfg.window_minutes,
|
||||
fail2ban_available=fail2ban_available(),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def _block_item(entry) -> LoginBlockItem:
|
||||
return LoginBlockItem(
|
||||
ip_address=entry.ip_address,
|
||||
scope=entry.scope,
|
||||
failure_count=entry.failure_count,
|
||||
usernames=list(entry.usernames),
|
||||
blocked_until=entry.blocked_until,
|
||||
reason=entry.reason,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login-security", response_model=LoginSecuritySettingsResponse)
|
||||
def get_login_security_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> LoginSecuritySettingsResponse:
|
||||
return _login_security_response(db)
|
||||
|
||||
|
||||
@router.put("/login-security", response_model=LoginSecuritySettingsResponse)
|
||||
def update_login_security_settings(
|
||||
body: LoginSecuritySettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> LoginSecuritySettingsResponse:
|
||||
from app.services.login_security_settings import upsert_login_security_settings
|
||||
|
||||
upsert_login_security_settings(
|
||||
db,
|
||||
ip_whitelist_text=body.ip_whitelist_text,
|
||||
sync_fail2ban=bool(body.sync_fail2ban),
|
||||
)
|
||||
return _login_security_response(db)
|
||||
|
||||
|
||||
@router.get("/login-security/blocks", response_model=list[LoginBlockItem])
|
||||
def list_login_security_blocks(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> list[LoginBlockItem]:
|
||||
from app.services.login_security_settings import list_all_login_blocks
|
||||
|
||||
return [_block_item(e) for e in list_all_login_blocks(db)]
|
||||
|
||||
|
||||
@router.post("/login-security/unblock", response_model=LoginUnblockResponse)
|
||||
def unblock_login_security_ip(
|
||||
body: LoginUnblockRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> LoginUnblockResponse:
|
||||
from app.services.login_security_settings import unblock_login_ip
|
||||
|
||||
scope = (body.scope or "both").strip().lower()
|
||||
if scope not in ("web", "ssh", "both"):
|
||||
raise HTTPException(status_code=422, detail="scope must be web, ssh, or both")
|
||||
messages = unblock_login_ip(db, body.ip_address, scope=scope)
|
||||
return LoginUnblockResponse(messages=messages)
|
||||
@@ -0,0 +1,88 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_auth import verify_access_token
|
||||
from app.auth.stream_cookie import STREAM_COOKIE_NAME
|
||||
from app.database import SessionLocal, get_db
|
||||
from app.models import Event, Problem
|
||||
from app.config import get_settings
|
||||
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
|
||||
from app.version import APP_VERSION
|
||||
|
||||
router = APIRouter(prefix="/stream", tags=["stream"])
|
||||
|
||||
SSE_INTERVAL_SEC = 5
|
||||
|
||||
|
||||
def _dashboard_snapshot(db: Session) -> dict:
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
events_24h = db.scalar(
|
||||
select(func.count()).select_from(Event).where(Event.received_at >= since)
|
||||
) or 0
|
||||
problems_open = (
|
||||
db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0
|
||||
)
|
||||
last_event = db.scalar(select(Event.id).order_by(Event.received_at.desc()).limit(1))
|
||||
settings = get_settings()
|
||||
return {
|
||||
"type": "dashboard",
|
||||
"version": APP_VERSION,
|
||||
"events_last_24h": events_24h,
|
||||
"hosts_stale": count_stale_hosts(db, settings.sac_heartbeat_stale_minutes),
|
||||
"heartbeats_24h": db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Event)
|
||||
.where(Event.received_at >= since, Event.type == HEARTBEAT_TYPE)
|
||||
)
|
||||
or 0,
|
||||
"daily_reports_24h": db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Event)
|
||||
.where(Event.received_at >= since, Event.type.in_(DAILY_REPORT_TYPES))
|
||||
)
|
||||
or 0,
|
||||
"problems_open": problems_open,
|
||||
"last_event_id": last_event,
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def _sse_generator():
|
||||
try:
|
||||
while True:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
payload = _dashboard_snapshot(db)
|
||||
finally:
|
||||
db.close()
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
await asyncio.sleep(SSE_INTERVAL_SEC)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
|
||||
def _sse_auth(
|
||||
sac_stream: str | None = Cookie(None, alias=STREAM_COOKIE_NAME),
|
||||
db: Session = Depends(get_db),
|
||||
) -> str:
|
||||
token = (sac_stream or "").strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="SSE authentication required")
|
||||
return verify_access_token(token, db=db)
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def stream_events(
|
||||
_user: str = Depends(_sse_auth),
|
||||
) -> StreamingResponse:
|
||||
return StreamingResponse(
|
||||
_sse_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.services.system_stats import collect_system_stats
|
||||
from app.services.ui_settings import get_effective_ui_settings
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
|
||||
class SystemStatsResponse(BaseModel):
|
||||
visible: bool = Field(description="Показывать блок в боковой панели (глобальная настройка)")
|
||||
collected_at: str
|
||||
cpu_percent: float | None = None
|
||||
memory: dict | None = None
|
||||
disk: dict | None = None
|
||||
database: dict
|
||||
app: dict
|
||||
|
||||
|
||||
@router.get("/stats", response_model=SystemStatsResponse)
|
||||
def get_system_stats(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(get_current_user),
|
||||
) -> SystemStatsResponse:
|
||||
ui = get_effective_ui_settings(db)
|
||||
payload = collect_system_stats(db)
|
||||
return SystemStatsResponse(visible=ui.show_sidebar_system_stats, **payload)
|
||||
@@ -0,0 +1,144 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.jwt_auth import CurrentUser, require_admin
|
||||
from app.database import get_db
|
||||
from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, USER_ROLES, User
|
||||
from app.services.user_auth import create_user, hash_password
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
class UserSummary(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
items: list[UserSummary]
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str = Field(min_length=2, max_length=64)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
role: str = Field(default=USER_ROLE_MONITOR)
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
username: str | None = Field(default=None, min_length=2, max_length=64)
|
||||
role: str | None = None
|
||||
password: str | None = Field(default=None, min_length=8, max_length=128)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@router.get("", response_model=UserListResponse)
|
||||
def list_users(
|
||||
_admin: CurrentUser = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserListResponse:
|
||||
rows = db.scalars(select(User).order_by(User.username)).all()
|
||||
return UserListResponse(
|
||||
items=[
|
||||
UserSummary(
|
||||
id=u.id,
|
||||
username=u.username,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
created_at=u.created_at,
|
||||
)
|
||||
for u in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=UserSummary, status_code=201)
|
||||
def create_user_endpoint(
|
||||
body: CreateUserRequest,
|
||||
admin: CurrentUser = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserSummary:
|
||||
if body.role not in USER_ROLES:
|
||||
raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}")
|
||||
try:
|
||||
user = create_user(db, username=body.username, password=body.password, role=body.role)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return UserSummary(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserSummary)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
body: UpdateUserRequest,
|
||||
admin: CurrentUser = Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserSummary:
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if body.username is not None:
|
||||
normalized = body.username.strip()
|
||||
if not normalized:
|
||||
raise HTTPException(status_code=422, detail="username is required")
|
||||
existing = db.scalar(
|
||||
select(User).where(
|
||||
func.lower(User.username) == normalized.lower(),
|
||||
User.id != user.id,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=400, detail="username already exists")
|
||||
user.username = normalized
|
||||
|
||||
if body.role is not None:
|
||||
if body.role not in USER_ROLES:
|
||||
raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}")
|
||||
if user.role == USER_ROLE_ADMIN and body.role != USER_ROLE_ADMIN:
|
||||
_ensure_not_last_admin(db, user.id)
|
||||
user.role = body.role
|
||||
|
||||
if body.password is not None:
|
||||
user.password_hash = hash_password(body.password)
|
||||
|
||||
if body.is_active is not None:
|
||||
if not body.is_active and user.username.lower() == admin.username.lower():
|
||||
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
|
||||
if not body.is_active and user.role == USER_ROLE_ADMIN:
|
||||
_ensure_not_last_admin(db, user.id)
|
||||
user.is_active = body.is_active
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserSummary(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_not_last_admin(db: Session, user_id: int) -> None:
|
||||
active_admins = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(User.role == USER_ROLE_ADMIN, User.is_active.is_(True), User.id != user_id)
|
||||
)
|
||||
if not active_admins:
|
||||
raise HTTPException(status_code=400, detail="Cannot remove or deactivate the last admin user")
|
||||
Reference in New Issue
Block a user