feat: RDG session flap rule, qwinsta/logoff queue and dashboard UI (0.9.13)
Detect 302→303 within 1–10s, Problem with 30s dedup, rdg_flap flag. Agent command poll API, admin qwinsta/logoff from Overview. Migration 016.
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
|
||||
## Статус
|
||||
|
||||
**Версия:** `0.9.12`
|
||||
**Версия:** `0.9.13`
|
||||
**Стек:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
|
||||
**Деплой:** `sudo /opt/sac-deploy.sh` (см. `deploy/sac-deploy.sh`)
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Self-hosted hub for collecting, storing, and displaying security events from Lin
|
||||
|
||||
## Status
|
||||
|
||||
**Version:** `0.9.12`
|
||||
**Version:** `0.9.13`
|
||||
**Stack:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
|
||||
**Deploy:** `sudo /opt/sac-deploy.sh` (see `deploy/sac-deploy.sh`)
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Revision ID: 016
|
||||
Revises: 015
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "016"
|
||||
down_revision: Union[str, None] = "015"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"agent_commands",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("command_uuid", sa.String(length=36), nullable=False),
|
||||
sa.Column("host_id", sa.Integer(), nullable=False),
|
||||
sa.Column("event_id", sa.Integer(), nullable=True),
|
||||
sa.Column("command_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("params", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, server_default="pending"),
|
||||
sa.Column("result_stdout", sa.Text(), nullable=True),
|
||||
sa.Column("result_stderr", sa.Text(), nullable=True),
|
||||
sa.Column("requested_by", sa.String(length=128), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("command_uuid"),
|
||||
)
|
||||
op.create_index("ix_agent_commands_command_uuid", "agent_commands", ["command_uuid"])
|
||||
op.create_index("ix_agent_commands_host_id", "agent_commands", ["host_id"])
|
||||
op.create_index("ix_agent_commands_status", "agent_commands", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_agent_commands_status", table_name="agent_commands")
|
||||
op.drop_index("ix_agent_commands_host_id", table_name="agent_commands")
|
||||
op.drop_index("ix_agent_commands_command_uuid", table_name="agent_commands")
|
||||
op.drop_table("agent_commands")
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
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 (
|
||||
command_to_dict,
|
||||
complete_command,
|
||||
get_command_for_host_poll,
|
||||
resolve_host_by_agent_instance_id,
|
||||
)
|
||||
|
||||
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):
|
||||
commands: list[dict[str, Any]]
|
||||
|
||||
|
||||
@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")
|
||||
pending = get_command_for_host_poll(db, host)
|
||||
return AgentCommandsPollResponse(
|
||||
commands=[command_to_dict(c, include_run_as=True) for c in pending]
|
||||
)
|
||||
|
||||
|
||||
@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}
|
||||
@@ -9,11 +9,17 @@ 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
|
||||
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 EventDetail, EventListResponse, EventSummary
|
||||
from app.services.agent_commands import (
|
||||
command_to_dict,
|
||||
get_command_by_uuid,
|
||||
queue_logoff,
|
||||
queue_qwinsta,
|
||||
)
|
||||
from app.services.ingest import ingest_event
|
||||
from app.services.event_summary import event_to_summary
|
||||
from app.services.problems import maybe_create_problem
|
||||
@@ -168,6 +174,94 @@ def list_events(
|
||||
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
|
||||
|
||||
|
||||
class LogoffActionBody(BaseModel):
|
||||
session_id: int
|
||||
|
||||
|
||||
@router.post("/{event_db_id}/actions/qwinsta", response_model=AgentCommandResponse)
|
||||
def post_event_qwinsta(
|
||||
event_db_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(require_admin),
|
||||
) -> AgentCommandResponse:
|
||||
event = db.get(Event, event_db_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
cmd = queue_qwinsta(db, event, requested_by=str(user))
|
||||
db.commit()
|
||||
data = command_to_dict(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"),
|
||||
)
|
||||
|
||||
|
||||
@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=Depends(require_admin),
|
||||
) -> AgentCommandResponse:
|
||||
event = db.get(Event, event_db_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
cmd = queue_logoff(
|
||||
db,
|
||||
event,
|
||||
session_id=body.session_id,
|
||||
requested_by=str(user),
|
||||
)
|
||||
db.commit()
|
||||
data = command_to_dict(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"),
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
data = command_to_dict(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"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{event_db_id}", response_model=EventDetail)
|
||||
def get_event(
|
||||
event_db_id: int,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users
|
||||
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)
|
||||
|
||||
@@ -102,6 +102,15 @@ class Settings(BaseSettings):
|
||||
sac_privilege_spike_window_minutes: int = 10
|
||||
sac_privilege_spike_threshold: int = 10
|
||||
|
||||
# rule:rdg_session_flap — RD Gateway 302→303 within window
|
||||
sac_rdg_flap_window_min_sec: int = 1
|
||||
sac_rdg_flap_window_max_sec: int = 10
|
||||
sac_rdg_flap_dedup_sec: int = 30
|
||||
|
||||
# Windows admin for agent qwinsta/logoff (domain-wide)
|
||||
sac_win_admin_user: str = ""
|
||||
sac_win_admin_password: str = ""
|
||||
|
||||
# Retention (app.jobs.retention / systemd timer)
|
||||
sac_events_retention_days: int = 90
|
||||
sac_problems_retention_days: int = 180
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from app.models.agent_command import AgentCommand
|
||||
from app.models.api_key import ApiKey
|
||||
from app.models.event import Event
|
||||
from app.models.host import Host
|
||||
@@ -16,6 +17,7 @@ from app.models.mobile_refresh_token import MobileRefreshToken
|
||||
|
||||
__all__ = [
|
||||
"ApiKey",
|
||||
"AgentCommand",
|
||||
"Event",
|
||||
"Host",
|
||||
"NotificationChannel",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AgentCommand(Base):
|
||||
__tablename__ = "agent_commands"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
command_uuid: Mapped[str] = mapped_column(String(36), unique=True, index=True)
|
||||
host_id: Mapped[int] = mapped_column(ForeignKey("hosts.id", ondelete="CASCADE"), index=True)
|
||||
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True)
|
||||
command_type: Mapped[str] = mapped_column(String(32))
|
||||
params: Mapped[dict | None] = mapped_column(JSONB, default=dict)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
|
||||
result_stdout: Mapped[str | None] = mapped_column(Text)
|
||||
result_stderr: Mapped[str | None] = mapped_column(Text)
|
||||
requested_by: Mapped[str | None] = mapped_column(String(128))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
host: Mapped["Host"] = relationship(back_populates="agent_commands")
|
||||
event: Mapped["Event | None"] = relationship()
|
||||
@@ -28,3 +28,4 @@ class Host(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
events: Mapped[list["Event"]] = relationship(back_populates="host")
|
||||
agent_commands: Mapped[list["AgentCommand"]] = relationship(back_populates="host")
|
||||
|
||||
@@ -55,6 +55,7 @@ class EventSummary(BaseModel):
|
||||
title: str
|
||||
summary: str
|
||||
actor_user: str | None = None
|
||||
rdg_flap: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Queue and resolve agent commands (qwinsta, logoff)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import AgentCommand, Event, Host
|
||||
from app.services.rdg_session_flap import event_has_rdg_flap
|
||||
|
||||
|
||||
def _win_admin_configured() -> bool:
|
||||
settings = get_settings()
|
||||
return bool(settings.sac_win_admin_user.strip() and settings.sac_win_admin_password.strip())
|
||||
|
||||
|
||||
def win_admin_run_as() -> dict[str, str] | None:
|
||||
if not _win_admin_configured():
|
||||
return None
|
||||
settings = get_settings()
|
||||
return {
|
||||
"user": settings.sac_win_admin_user.strip(),
|
||||
"password": settings.sac_win_admin_password,
|
||||
}
|
||||
|
||||
|
||||
def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict:
|
||||
out = {
|
||||
"id": cmd.command_uuid,
|
||||
"type": cmd.command_type,
|
||||
"params": cmd.params if isinstance(cmd.params, dict) else {},
|
||||
"status": cmd.status,
|
||||
"result_stdout": cmd.result_stdout,
|
||||
"result_stderr": cmd.result_stderr,
|
||||
"created_at": cmd.created_at.isoformat() if cmd.created_at else None,
|
||||
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
|
||||
}
|
||||
if include_run_as and cmd.status == "pending":
|
||||
run_as = win_admin_run_as()
|
||||
if run_as:
|
||||
out["run_as"] = run_as
|
||||
return out
|
||||
|
||||
|
||||
def queue_qwinsta(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
||||
if not event_has_rdg_flap(event):
|
||||
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap")
|
||||
if not _win_admin_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="SAC_WIN_ADMIN_USER / SAC_WIN_ADMIN_PASSWORD not configured",
|
||||
)
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
cmd = AgentCommand(
|
||||
command_uuid=str(uuid.uuid4()),
|
||||
host_id=event.host_id,
|
||||
event_id=event.id,
|
||||
command_type="qwinsta",
|
||||
params={"user": user} if user else {},
|
||||
status="pending",
|
||||
requested_by=requested_by,
|
||||
)
|
||||
db.add(cmd)
|
||||
db.flush()
|
||||
return cmd
|
||||
|
||||
|
||||
def queue_logoff(
|
||||
db: Session,
|
||||
event: Event,
|
||||
*,
|
||||
session_id: int,
|
||||
requested_by: str,
|
||||
) -> AgentCommand:
|
||||
if not event_has_rdg_flap(event):
|
||||
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap")
|
||||
if not _win_admin_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="SAC_WIN_ADMIN_USER / SAC_WIN_ADMIN_PASSWORD not configured",
|
||||
)
|
||||
cmd = AgentCommand(
|
||||
command_uuid=str(uuid.uuid4()),
|
||||
host_id=event.host_id,
|
||||
event_id=event.id,
|
||||
command_type="logoff",
|
||||
params={"session_id": session_id},
|
||||
status="pending",
|
||||
requested_by=requested_by,
|
||||
)
|
||||
db.add(cmd)
|
||||
db.flush()
|
||||
return cmd
|
||||
|
||||
|
||||
def get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(AgentCommand)
|
||||
.where(
|
||||
AgentCommand.host_id == host.id,
|
||||
AgentCommand.status == "pending",
|
||||
)
|
||||
.order_by(AgentCommand.created_at.asc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def resolve_host_by_agent_instance_id(db: Session, agent_instance_id: str) -> Host | None:
|
||||
if not agent_instance_id.strip():
|
||||
return None
|
||||
return db.scalar(select(Host).where(Host.agent_instance_id == agent_instance_id.strip()))
|
||||
|
||||
|
||||
def complete_command(
|
||||
db: Session,
|
||||
command_uuid: str,
|
||||
*,
|
||||
status: str,
|
||||
stdout: str | None = None,
|
||||
stderr: str | None = None,
|
||||
) -> AgentCommand | None:
|
||||
cmd = db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
|
||||
if cmd is None:
|
||||
return None
|
||||
if cmd.status != "pending":
|
||||
return cmd
|
||||
cmd.status = status
|
||||
cmd.result_stdout = stdout
|
||||
cmd.result_stderr = stderr
|
||||
cmd.completed_at = datetime.now(timezone.utc)
|
||||
db.flush()
|
||||
return cmd
|
||||
|
||||
|
||||
def get_command_by_uuid(db: Session, command_uuid: str) -> AgentCommand | None:
|
||||
return db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
|
||||
@@ -1,6 +1,7 @@
|
||||
from app.models.event import Event
|
||||
from app.schemas.list_models import EventSummary
|
||||
from app.services.event_actor_user import extract_event_actor_user
|
||||
from app.services.rdg_session_flap import event_has_rdg_flap
|
||||
|
||||
|
||||
def event_to_summary(event: Event) -> EventSummary:
|
||||
@@ -20,4 +21,5 @@ def event_to_summary(event: Event) -> EventSummary:
|
||||
title=event.title,
|
||||
summary=event.summary,
|
||||
actor_user=extract_event_actor_user(event.type, event.details),
|
||||
rdg_flap=event_has_rdg_flap(event),
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ PRIVILEGE_SUDO_TYPE = "privilege.sudo.command"
|
||||
RULE_BRUTE_FORCE = "rule:brute_force_burst"
|
||||
RULE_PRIVILEGE_SPIKE = "rule:privilege_spike"
|
||||
RULE_HOST_SILENCE = "rule:host_silence"
|
||||
RULE_RDG_SESSION_FLAP = "rule:rdg_session_flap"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -204,8 +205,11 @@ def build_fingerprint(host_id: int, correlation_type: str, rule_id: str, suffix:
|
||||
|
||||
|
||||
def pick_rule_match(db: Session, event: Event) -> RuleMatch | None:
|
||||
"""Первое сработавшее правило (приоритет: burst → spike → silence → immediate)."""
|
||||
"""Первое сработавшее правило (приоритет: rdg flap → burst → spike → silence → immediate)."""
|
||||
from app.services.rdg_session_flap import evaluate_rdg_session_flap
|
||||
|
||||
for evaluator in (
|
||||
evaluate_rdg_session_flap,
|
||||
evaluate_brute_force_burst,
|
||||
evaluate_privilege_spike,
|
||||
evaluate_host_silence,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Auto-create Problems from ingested events (rules + correlation)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -11,6 +11,7 @@ from app.services.host_health import HEARTBEAT_TYPE
|
||||
from app.services.problem_rules import (
|
||||
RuleMatch,
|
||||
RULE_HOST_SILENCE,
|
||||
RULE_RDG_SESSION_FLAP,
|
||||
build_fingerprint,
|
||||
pick_rule_match,
|
||||
resolve_host_silence_problems,
|
||||
@@ -18,8 +19,6 @@ from app.services.problem_rules import (
|
||||
|
||||
|
||||
def _correlation_cutoff(now: datetime) -> datetime:
|
||||
from datetime import timedelta
|
||||
|
||||
minutes = get_settings().sac_problem_correlation_window_minutes
|
||||
return now - timedelta(minutes=minutes)
|
||||
|
||||
@@ -66,7 +65,10 @@ def open_or_append_problem(
|
||||
match.fingerprint_suffix,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = _correlation_cutoff(now)
|
||||
if match.rule_id == RULE_RDG_SESSION_FLAP:
|
||||
cutoff = now - timedelta(seconds=get_settings().sac_rdg_flap_dedup_sec)
|
||||
else:
|
||||
cutoff = _correlation_cutoff(now)
|
||||
|
||||
open_problem = db.scalar(
|
||||
select(Problem)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""RDG 302→303 session flap: detect, flag event, build Problem match."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Event
|
||||
from app.services.problem_rules import RULE_RDG_SESSION_FLAP, RuleMatch
|
||||
|
||||
RDG_SUCCESS_TYPE = "rdg.connection.success"
|
||||
RDG_END_TYPES = frozenset({"rdg.connection.disconnected", "rdg.connection.failed"})
|
||||
|
||||
|
||||
def _event_user(event: Event) -> str:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
return str(user).strip() if user else ""
|
||||
|
||||
|
||||
def _event_internal_ip(event: Event) -> str:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
for key in ("internal_ip", "client_ip", "ip_address"):
|
||||
val = details.get(key)
|
||||
if val:
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _users_match(end_event: Event, success_event: Event) -> bool:
|
||||
return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event)
|
||||
|
||||
|
||||
def _internal_ips_compatible(end_event: Event, success_event: Event) -> bool:
|
||||
end_ip = _event_internal_ip(end_event)
|
||||
success_ip = _event_internal_ip(success_event)
|
||||
if not end_ip or not success_ip:
|
||||
return True
|
||||
return end_ip == success_ip
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def find_rdg_success_before_end(db: Session, end_event: Event) -> Event | None:
|
||||
if end_event.type not in RDG_END_TYPES:
|
||||
return None
|
||||
settings = get_settings()
|
||||
min_sec = settings.sac_rdg_flap_window_min_sec
|
||||
max_sec = settings.sac_rdg_flap_window_max_sec
|
||||
end_at = _as_utc(end_event.occurred_at)
|
||||
|
||||
window_start = end_at - timedelta(seconds=max_sec)
|
||||
window_end = end_at - timedelta(seconds=min_sec)
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == end_event.host_id,
|
||||
Event.type == RDG_SUCCESS_TYPE,
|
||||
Event.occurred_at >= window_start,
|
||||
Event.occurred_at <= window_end,
|
||||
Event.id != end_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
for prior in candidates:
|
||||
if not _users_match(end_event, prior):
|
||||
continue
|
||||
if not _internal_ips_compatible(end_event, prior):
|
||||
continue
|
||||
prior_at = _as_utc(prior.occurred_at)
|
||||
delta = (end_at - prior_at).total_seconds()
|
||||
if min_sec <= delta <= max_sec:
|
||||
return prior
|
||||
return None
|
||||
|
||||
|
||||
def mark_rdg_flap(event: Event, *, pair_event: Event) -> None:
|
||||
details = dict(event.details) if isinstance(event.details, dict) else {}
|
||||
details["rdg_flap"] = True
|
||||
details["rdg_flap_pair_event_id"] = pair_event.id
|
||||
event.details = details
|
||||
flag_modified(event, "details")
|
||||
|
||||
|
||||
def evaluate_rdg_session_flap(db: Session, event: Event) -> RuleMatch | None:
|
||||
prior = find_rdg_success_before_end(db, event)
|
||||
if prior is None:
|
||||
return None
|
||||
|
||||
mark_rdg_flap(event, pair_event=prior)
|
||||
user = _event_user(event)
|
||||
internal_ip = _event_internal_ip(event)
|
||||
ip_note = f", client {internal_ip}" if internal_ip else ""
|
||||
delta_sec = int((_as_utc(event.occurred_at) - _as_utc(prior.occurred_at)).total_seconds())
|
||||
return RuleMatch(
|
||||
rule_id=RULE_RDG_SESSION_FLAP,
|
||||
correlation_type="rdg.session.flap",
|
||||
fingerprint_suffix=f"u{user}:ip{internal_ip or 'any'}",
|
||||
title=f"RDG session flap: {user}",
|
||||
summary=(
|
||||
f"302→303 за {delta_sec} с "
|
||||
f"({user}{ip_note}). Возможна зависшая сессия на ПК пользователя — qwinsta/logoff."
|
||||
),
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
|
||||
def event_has_rdg_flap(event: Event) -> bool:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
return details.get("rdg_flap") is True
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
|
||||
APP_NAME = "Security Alert Center"
|
||||
APP_VERSION = "0.9.12"
|
||||
APP_VERSION = "0.9.13"
|
||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||
|
||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
||||
|
||||
|
||||
def test_version_constants():
|
||||
assert APP_VERSION == "0.9.12"
|
||||
assert APP_VERSION == "0.9.13"
|
||||
assert APP_NAME == "Security Alert Center"
|
||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.9.12"
|
||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.9.13"
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Tests for RDG 302→303 session flap rule."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Problem
|
||||
from app.services.ingest import ingest_event
|
||||
from app.services.problems import maybe_create_problem
|
||||
from app.services.problem_rules import RULE_RDG_SESSION_FLAP
|
||||
from app.services.rdg_session_flap import (
|
||||
evaluate_rdg_session_flap,
|
||||
event_has_rdg_flap,
|
||||
find_rdg_success_before_end,
|
||||
)
|
||||
from tests.test_ingest import VALID_EVENT
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
base = {
|
||||
**VALID_EVENT,
|
||||
"event_id": str(uuid.uuid4()),
|
||||
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _ingest(db, occurred_at: datetime, **overrides):
|
||||
payload = _payload(**overrides)
|
||||
payload["occurred_at"] = occurred_at.isoformat()
|
||||
event, _ = ingest_event(db, payload)
|
||||
db.flush()
|
||||
return event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rdg_settings(monkeypatch):
|
||||
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1")
|
||||
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10")
|
||||
monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30")
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_rdg_flap_detects_302_then_303(db_session, rdg_settings):
|
||||
t0 = datetime.now(timezone.utc)
|
||||
user = "B26\\test.user"
|
||||
details = {"user": user, "internal_ip": "192.168.163.32"}
|
||||
|
||||
_ingest(
|
||||
db_session,
|
||||
t0,
|
||||
type="rdg.connection.success",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RD Gateway event 302",
|
||||
summary="302",
|
||||
details=details,
|
||||
)
|
||||
end = _ingest(
|
||||
db_session,
|
||||
t0 + timedelta(seconds=4),
|
||||
type="rdg.connection.disconnected",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="RD Gateway event 303",
|
||||
summary="303",
|
||||
details=details,
|
||||
)
|
||||
|
||||
match = evaluate_rdg_session_flap(db_session, end)
|
||||
assert match is not None
|
||||
assert match.rule_id == RULE_RDG_SESSION_FLAP
|
||||
assert event_has_rdg_flap(end) is True
|
||||
|
||||
problem, created = maybe_create_problem(db_session, end)
|
||||
assert created is True
|
||||
assert problem.rule_id == RULE_RDG_SESSION_FLAP
|
||||
|
||||
|
||||
def test_rdg_flap_ignores_gap_over_10_sec(db_session, rdg_settings):
|
||||
t0 = datetime.now(timezone.utc)
|
||||
user = "B26\\slow.user"
|
||||
details = {"user": user}
|
||||
|
||||
_ingest(
|
||||
db_session,
|
||||
t0,
|
||||
type="rdg.connection.success",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="302",
|
||||
summary="302",
|
||||
details=details,
|
||||
)
|
||||
end = _ingest(
|
||||
db_session,
|
||||
t0 + timedelta(seconds=15),
|
||||
type="rdg.connection.disconnected",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="303",
|
||||
summary="303",
|
||||
details=details,
|
||||
)
|
||||
|
||||
assert find_rdg_success_before_end(db_session, end) is None
|
||||
assert evaluate_rdg_session_flap(db_session, end) is None
|
||||
|
||||
|
||||
def test_rdg_flap_dedup_within_30_sec(db_session, rdg_settings):
|
||||
t0 = datetime.now(timezone.utc)
|
||||
user = "B26\\dup.user"
|
||||
details = {"user": user}
|
||||
|
||||
def pair(at: datetime):
|
||||
_ingest(
|
||||
db_session,
|
||||
at,
|
||||
type="rdg.connection.success",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="302",
|
||||
summary="302",
|
||||
details=details,
|
||||
)
|
||||
return _ingest(
|
||||
db_session,
|
||||
at + timedelta(seconds=5),
|
||||
type="rdg.connection.disconnected",
|
||||
category="auth",
|
||||
severity="info",
|
||||
title="303",
|
||||
summary="303",
|
||||
details=details,
|
||||
)
|
||||
|
||||
end1 = pair(t0)
|
||||
maybe_create_problem(db_session, end1)
|
||||
db_session.flush()
|
||||
|
||||
end2 = pair(t0 + timedelta(seconds=10))
|
||||
maybe_create_problem(db_session, end2)
|
||||
db_session.flush()
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
open_count = db_session.scalar(
|
||||
select(func.count()).select_from(Problem).where(
|
||||
Problem.rule_id == RULE_RDG_SESSION_FLAP,
|
||||
Problem.status == "open",
|
||||
)
|
||||
)
|
||||
assert open_count == 1
|
||||
@@ -79,6 +79,15 @@ SAC_BRUTE_FORCE_THRESHOLD=30
|
||||
SAC_PRIVILEGE_SPIKE_WINDOW_MINUTES=10
|
||||
SAC_PRIVILEGE_SPIKE_THRESHOLD=10
|
||||
|
||||
# rule:rdg_session_flap (302→303)
|
||||
SAC_RDG_FLAP_WINDOW_MIN_SEC=1
|
||||
SAC_RDG_FLAP_WINDOW_MAX_SEC=10
|
||||
SAC_RDG_FLAP_DEDUP_SEC=30
|
||||
|
||||
# qwinsta/logoff на Windows-хостах (доменный admin)
|
||||
# SAC_WIN_ADMIN_USER=B26\\Administrator
|
||||
# SAC_WIN_ADMIN_PASSWORD=
|
||||
|
||||
# Retention (systemd sac-retention.timer)
|
||||
SAC_EVENTS_RETENTION_DAYS=90
|
||||
SAC_PROBLEMS_RETENTION_DAYS=180
|
||||
|
||||
+11
-11
@@ -282,18 +282,18 @@ Authorization: Bearer sac_xxx
|
||||
|
||||
## 8. Фазы реализации
|
||||
|
||||
| Фаза | Содержание | Репозитории |
|
||||
|------|------------|-------------|
|
||||
| **1** | `rule:rdg_session_flap`, Problem, notify, флаг `rdg_flap` на event | SAC |
|
||||
| **2** | Колонка «Действия» на «Обзоре»; кнопка qwinsta (disabled до фазы 3) | SAC UI |
|
||||
| **3** | Poll API, очередь команд, доменный admin в Settings | SAC + RDP-login-monitor |
|
||||
| **4** | qwinsta modal, logoff (match user + выбор строки) | SAC UI + RDP agent |
|
||||
| **5** | Settings «Обновления агентов», режим A/B | SAC |
|
||||
| **6** | Self-update B1 (Windows + Linux) | SAC + оба агента |
|
||||
| **7** | SSH bootstrap + WinRM fallback B2 | SAC |
|
||||
| **8** | Desired config per host (П.3) | SAC + оба агента |
|
||||
| Фаза | Содержание | Статус |
|
||||
|------|------------|--------|
|
||||
| **1** | `rule:rdg_session_flap`, Problem, notify, флаг `rdg_flap` | ✅ SAC |
|
||||
| **2** | Кнопка **qwinsta** на «Обзоре» | ✅ SAC UI |
|
||||
| **3** | Poll API, `agent_commands`, env admin | ✅ SAC; ⏳ RDP-login-monitor |
|
||||
| **4** | logoff в модалке | ✅ SAC UI |
|
||||
| **5** | Settings «Обновления агентов» | ⏳ |
|
||||
| **6** | Self-update B1 | ⏳ |
|
||||
| **7** | SSH bootstrap + WinRM fallback | ⏳ |
|
||||
| **8** | Desired config per host | ⏳ |
|
||||
|
||||
**Старт разработки:** фаза **1** (детект без команд агента).
|
||||
**Миграция:** `016_agent_commands`. Env: `SAC_RDG_FLAP_*`, `SAC_WIN_ADMIN_USER`, `SAC_WIN_ADMIN_PASSWORD`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -169,6 +169,34 @@ export interface EventSummary {
|
||||
title: string;
|
||||
summary: string;
|
||||
actor_user?: string | null;
|
||||
rdg_flap?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentCommandResponse {
|
||||
command_uuid: string;
|
||||
command_type: string;
|
||||
status: string;
|
||||
result_stdout?: string | null;
|
||||
result_stderr?: string | null;
|
||||
created_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
}
|
||||
|
||||
export function postEventQwinsta(eventId: number): Promise<AgentCommandResponse> {
|
||||
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/qwinsta`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function postEventLogoff(eventId: number, sessionId: number): Promise<AgentCommandResponse> {
|
||||
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/logoff`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchEventAction(eventId: number, commandUuid: string): Promise<AgentCommandResponse> {
|
||||
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/${commandUuid}`);
|
||||
}
|
||||
|
||||
export interface EventListResponse {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||
export const APP_NAME = "Security Alert Center";
|
||||
export const APP_VERSION = "0.9.12";
|
||||
export const APP_VERSION = "0.9.13";
|
||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||
|
||||
@@ -245,6 +245,8 @@
|
||||
|
||||
<th>Title</th>
|
||||
|
||||
<th>Действия</th>
|
||||
|
||||
</tr>
|
||||
|
||||
</thead>
|
||||
@@ -281,12 +283,70 @@
|
||||
|
||||
<td>{{ e.title }}</td>
|
||||
|
||||
<td class="dash-actions">
|
||||
|
||||
<button
|
||||
v-if="e.rdg_flap"
|
||||
type="button"
|
||||
class="secondary dash-qwinsta-btn"
|
||||
:disabled="qwinstaLoadingId === e.id"
|
||||
@click="runQwinsta(e)"
|
||||
>
|
||||
qwinsta
|
||||
</button>
|
||||
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
<div v-if="qwinstaModal.open" class="modal-backdrop" @click.self="closeQwinstaModal">
|
||||
<div class="modal-card qwinsta-modal" role="dialog" aria-modal="true">
|
||||
<h3>qwinsta — событие #{{ qwinstaModal.eventId }}</h3>
|
||||
<p v-if="qwinstaModal.error" class="error">{{ qwinstaModal.error }}</p>
|
||||
<p v-else-if="qwinstaModal.loading">Ожидание ответа агента…</p>
|
||||
<template v-else>
|
||||
<pre v-if="qwinstaModal.stdout" class="qwinsta-raw">{{ qwinstaModal.stdout }}</pre>
|
||||
<p v-else class="muted">Нет вывода</p>
|
||||
<table v-if="qwinstaModal.sessions.length" class="dash-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SESSION</th>
|
||||
<th>USER</th>
|
||||
<th>ID</th>
|
||||
<th>STATE</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in qwinstaModal.sessions" :key="s.id">
|
||||
<td>{{ s.sessionName }}</td>
|
||||
<td>{{ s.userName }}</td>
|
||||
<td>{{ s.id }}</td>
|
||||
<td>{{ s.state }}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
:disabled="qwinstaModal.logoffId === s.id"
|
||||
@click="runLogoff(s.id)"
|
||||
>
|
||||
logoff
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" @click="closeQwinstaModal">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters" style="margin-top: 1rem">
|
||||
|
||||
<button type="button" class="secondary" @click="load">Обновить всё</button>
|
||||
@@ -305,7 +365,16 @@
|
||||
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
|
||||
import { apiFetch, getToken, type DashboardSummary, type EventSummary } from "../api";
|
||||
import {
|
||||
apiFetch,
|
||||
fetchEventAction,
|
||||
getToken,
|
||||
postEventLogoff,
|
||||
postEventQwinsta,
|
||||
type AgentCommandResponse,
|
||||
type DashboardSummary,
|
||||
type EventSummary,
|
||||
} from "../api";
|
||||
import { useSacVersion } from "../composables/useSacVersion";
|
||||
import { formatServerName } from "../utils/hostDisplay";
|
||||
|
||||
@@ -327,6 +396,166 @@ let eventSource: EventSource | null = null;
|
||||
|
||||
let refreshingRecent = false;
|
||||
|
||||
let qwinstaPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const qwinstaLoadingId = ref<number | null>(null);
|
||||
|
||||
type QwinstaSessionRow = {
|
||||
sessionName: string;
|
||||
userName: string;
|
||||
id: number;
|
||||
state: string;
|
||||
};
|
||||
|
||||
const qwinstaModal = ref({
|
||||
open: false,
|
||||
eventId: 0,
|
||||
actorUser: "",
|
||||
commandUuid: "",
|
||||
loading: false,
|
||||
error: "",
|
||||
stdout: "",
|
||||
sessions: [] as QwinstaSessionRow[],
|
||||
logoffId: null as number | null,
|
||||
});
|
||||
|
||||
function parseQwinstaSessions(stdout: string, actorUser: string): QwinstaSessionRow[] {
|
||||
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
const rows: QwinstaSessionRow[] = [];
|
||||
for (const line of lines) {
|
||||
if (/^SESSION/i.test(line) || /^---/.test(line)) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
const sessionName = parts[0];
|
||||
const userName = parts[1];
|
||||
const id = Number.parseInt(parts[2], 10);
|
||||
const state = parts.slice(3).join(" ");
|
||||
if (!Number.isFinite(id)) continue;
|
||||
const norm = (s: string) => s.replace(/^B26\\/i, "").toLowerCase();
|
||||
const matchUser = actorUser && norm(userName).includes(norm(actorUser));
|
||||
if (actorUser && !matchUser) continue;
|
||||
rows.push({ sessionName, userName, id, state });
|
||||
}
|
||||
if (rows.length === 0 && stdout.trim()) {
|
||||
for (const line of lines) {
|
||||
if (/^SESSION/i.test(line) || /^---/.test(line)) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
const id = Number.parseInt(parts[2], 10);
|
||||
if (!Number.isFinite(id)) continue;
|
||||
rows.push({
|
||||
sessionName: parts[0],
|
||||
userName: parts[1],
|
||||
id,
|
||||
state: parts.slice(3).join(" "),
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function stopQwinstaPoll() {
|
||||
if (qwinstaPollTimer) {
|
||||
clearInterval(qwinstaPollTimer);
|
||||
qwinstaPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeQwinstaModal() {
|
||||
stopQwinstaPoll();
|
||||
qwinstaModal.value.open = false;
|
||||
}
|
||||
|
||||
function applyCommandResult(eventId: number, actorUser: string, cmd: AgentCommandResponse) {
|
||||
if (cmd.status === "pending") return false;
|
||||
qwinstaModal.value.loading = false;
|
||||
if (cmd.status === "failed") {
|
||||
qwinstaModal.value.error = cmd.result_stderr || cmd.result_stdout || "Команда завершилась с ошибкой";
|
||||
return true;
|
||||
}
|
||||
qwinstaModal.value.stdout = cmd.result_stdout || "";
|
||||
qwinstaModal.value.sessions = parseQwinstaSessions(qwinstaModal.value.stdout, actorUser);
|
||||
return true;
|
||||
}
|
||||
|
||||
function pollQwinstaCommand(eventId: number, commandUuid: string, actorUser: string) {
|
||||
stopQwinstaPoll();
|
||||
qwinstaPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const cmd = await fetchEventAction(eventId, commandUuid);
|
||||
if (applyCommandResult(eventId, actorUser, cmd)) {
|
||||
stopQwinstaPoll();
|
||||
}
|
||||
} catch (e) {
|
||||
qwinstaModal.value.loading = false;
|
||||
qwinstaModal.value.error = e instanceof Error ? e.message : "Ошибка опроса команды";
|
||||
stopQwinstaPoll();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function runQwinsta(event: EventSummary) {
|
||||
qwinstaLoadingId.value = event.id;
|
||||
qwinstaModal.value = {
|
||||
open: true,
|
||||
eventId: event.id,
|
||||
actorUser: event.actor_user || "",
|
||||
commandUuid: "",
|
||||
loading: true,
|
||||
error: "",
|
||||
stdout: "",
|
||||
sessions: [],
|
||||
logoffId: null,
|
||||
};
|
||||
try {
|
||||
const cmd = await postEventQwinsta(event.id);
|
||||
qwinstaModal.value.commandUuid = cmd.command_uuid;
|
||||
if (applyCommandResult(event.id, event.actor_user || "", cmd)) {
|
||||
return;
|
||||
}
|
||||
pollQwinstaCommand(event.id, cmd.command_uuid, event.actor_user || "");
|
||||
} catch (e) {
|
||||
qwinstaModal.value.loading = false;
|
||||
qwinstaModal.value.error = e instanceof Error ? e.message : "Не удалось отправить qwinsta";
|
||||
} finally {
|
||||
qwinstaLoadingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runLogoff(sessionId: number) {
|
||||
const modal = qwinstaModal.value;
|
||||
if (!modal.eventId) return;
|
||||
if (!window.confirm(`Завершить сеанс ID ${sessionId}?`)) return;
|
||||
modal.logoffId = sessionId;
|
||||
modal.error = "";
|
||||
try {
|
||||
const cmd = await postEventLogoff(modal.eventId, sessionId);
|
||||
if (cmd.status === "pending") {
|
||||
const deadline = Date.now() + 60000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const polled = await fetchEventAction(modal.eventId, cmd.command_uuid);
|
||||
if (polled.status !== "pending") {
|
||||
if (polled.status === "failed") {
|
||||
modal.error = polled.result_stderr || "logoff failed";
|
||||
} else {
|
||||
modal.loading = true;
|
||||
modal.stdout = "";
|
||||
modal.sessions = [];
|
||||
const again = await postEventQwinsta(modal.eventId);
|
||||
pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
modal.error = e instanceof Error ? e.message : "logoff error";
|
||||
} finally {
|
||||
modal.logoffId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDt(iso: string) {
|
||||
|
||||
return new Date(iso).toLocaleString("ru-RU");
|
||||
@@ -553,6 +782,8 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
stopQwinstaPoll();
|
||||
|
||||
eventSource?.close();
|
||||
|
||||
eventSource = null;
|
||||
@@ -587,6 +818,53 @@ onUnmounted(() => {
|
||||
|
||||
}
|
||||
|
||||
.dash-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-qwinsta-btn {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--card-bg, #1e2430);
|
||||
border: 1px solid var(--border, #3a4556);
|
||||
border-radius: 8px;
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
overflow: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.qwinsta-raw {
|
||||
font-size: 0.8rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
background: #0d1117;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user