feat: RDG qwinsta/logoff via WinRM on client workstation (0.11.6)
SAC resolves internal_ip to a registered Windows host and runs qwinsta/logoff synchronously over WinRM instead of queueing commands to the gateway agent.
This commit is contained in:
@@ -15,11 +15,11 @@ 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_response_fields,
|
||||
command_to_dict,
|
||||
get_command_by_uuid,
|
||||
queue_logoff,
|
||||
queue_qwinsta,
|
||||
)
|
||||
from app.services.rdg_winrm_actions import execute_logoff_via_winrm, execute_qwinsta_via_winrm
|
||||
from app.services.ingest import ingest_event
|
||||
from app.services.event_summary import event_to_summary
|
||||
from app.services.problems import maybe_create_problem
|
||||
@@ -182,6 +182,26 @@ class AgentCommandResponse(BaseModel):
|
||||
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):
|
||||
@@ -197,18 +217,9 @@ def post_event_qwinsta(
|
||||
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))
|
||||
cmd = execute_qwinsta_via_winrm(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"),
|
||||
)
|
||||
return _agent_command_response(cmd)
|
||||
|
||||
|
||||
@router.post("/{event_db_id}/actions/logoff", response_model=AgentCommandResponse)
|
||||
@@ -221,23 +232,14 @@ def post_event_logoff(
|
||||
event = db.get(Event, event_db_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
cmd = queue_logoff(
|
||||
cmd = execute_logoff_via_winrm(
|
||||
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"),
|
||||
)
|
||||
return _agent_command_response(cmd)
|
||||
|
||||
|
||||
@router.get("/{event_db_id}/actions/{command_uuid}", response_model=AgentCommandResponse)
|
||||
@@ -250,16 +252,7 @@ def get_event_action_status(
|
||||
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"),
|
||||
)
|
||||
return _agent_command_response(cmd)
|
||||
|
||||
|
||||
@router.get("/{event_db_id}", response_model=EventDetail)
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
"""Queue and resolve agent commands (qwinsta, logoff)."""
|
||||
"""Queue and resolve agent commands (legacy poll path + helpers)."""
|
||||
|
||||
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
|
||||
from app.models import AgentCommand, Host
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
|
||||
|
||||
@@ -47,56 +44,13 @@ def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict:
|
||||
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="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
|
||||
)
|
||||
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="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
|
||||
)
|
||||
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 command_response_fields(cmd: AgentCommand) -> dict:
|
||||
params = cmd.params if isinstance(cmd.params, dict) else {}
|
||||
return {
|
||||
"target": params.get("winrm_target") or params.get("client_hostname"),
|
||||
"client_hostname": params.get("client_hostname"),
|
||||
"internal_ip": params.get("internal_ip"),
|
||||
}
|
||||
|
||||
|
||||
def get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Resolve RDG client workstation (session host) from event internal_ip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.rdg_session_flap import event_internal_ip
|
||||
from app.services.winrm_connect import is_windows_host
|
||||
|
||||
|
||||
class ClientWorkstationNotFoundError(Exception):
|
||||
def __init__(self, internal_ip: str) -> None:
|
||||
self.internal_ip = internal_ip
|
||||
super().__init__(
|
||||
f"Client workstation not found in SAC hosts for internal_ip={internal_ip}. "
|
||||
"Ensure the PC is registered with matching ipv4."
|
||||
)
|
||||
|
||||
|
||||
def find_windows_host_by_ipv4(db: Session, ipv4: str) -> Host | None:
|
||||
ip = (ipv4 or "").strip()
|
||||
if not ip:
|
||||
return None
|
||||
rows = db.scalars(
|
||||
select(Host)
|
||||
.where(
|
||||
Host.ipv4 == ip,
|
||||
or_(
|
||||
Host.os_family.ilike("windows"),
|
||||
Host.product == "rdp-login-monitor",
|
||||
),
|
||||
)
|
||||
.order_by(Host.last_seen_at.desc())
|
||||
).all()
|
||||
for host in rows:
|
||||
if is_windows_host(host):
|
||||
return host
|
||||
return None
|
||||
|
||||
|
||||
def resolve_client_workstation(db: Session, event: Event) -> Host:
|
||||
internal_ip = event_internal_ip(event)
|
||||
if not internal_ip:
|
||||
raise ClientWorkstationNotFoundError("")
|
||||
host = find_windows_host_by_ipv4(db, internal_ip)
|
||||
if host is None:
|
||||
raise ClientWorkstationNotFoundError(internal_ip)
|
||||
return host
|
||||
@@ -31,6 +31,10 @@ def _event_internal_ip(event: Event) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def event_internal_ip(event: Event) -> str:
|
||||
return _event_internal_ip(event)
|
||||
|
||||
|
||||
def _users_match(end_event: Event, success_event: Event) -> bool:
|
||||
return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event)
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""RDG qwinsta/logoff via WinRM on client workstation (variant B)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AgentCommand, Event, Host
|
||||
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
|
||||
from app.services.rdg_session_flap import event_has_rdg_flap, event_internal_ip
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
from app.services.winrm_connect import (
|
||||
WinRmCmdResult,
|
||||
run_winrm_logoff,
|
||||
run_winrm_on_host_targets,
|
||||
run_winrm_qwinsta,
|
||||
)
|
||||
|
||||
|
||||
def _require_win_admin(db: Session):
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def _require_rdg_flap(event: Event) -> None:
|
||||
if not event_has_rdg_flap(event):
|
||||
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap")
|
||||
|
||||
|
||||
def _resolve_client(db: Session, event: Event) -> Host:
|
||||
try:
|
||||
return resolve_client_workstation(db, event)
|
||||
except ClientWorkstationNotFoundError as exc:
|
||||
if not exc.internal_ip:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Event has no internal_ip (client workstation address)",
|
||||
) from exc
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _format_result_message(result: WinRmCmdResult, *, attempts: list[str]) -> str:
|
||||
message = result.message
|
||||
if attempts:
|
||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||
return message
|
||||
|
||||
|
||||
def _persist_command(
|
||||
db: Session,
|
||||
*,
|
||||
client_host: Host,
|
||||
event: Event,
|
||||
command_type: str,
|
||||
params: dict,
|
||||
requested_by: str,
|
||||
result: WinRmCmdResult,
|
||||
attempts: list[str],
|
||||
) -> AgentCommand:
|
||||
now = datetime.now(timezone.utc)
|
||||
cmd = AgentCommand(
|
||||
command_uuid=str(uuid.uuid4()),
|
||||
host_id=client_host.id,
|
||||
event_id=event.id,
|
||||
command_type=command_type,
|
||||
params=params,
|
||||
status="completed" if result.ok else "failed",
|
||||
result_stdout=result.stdout or None,
|
||||
result_stderr=(result.stderr or _format_result_message(result, attempts=attempts) or None)
|
||||
if not result.ok
|
||||
else None,
|
||||
requested_by=requested_by,
|
||||
created_at=now,
|
||||
completed_at=now,
|
||||
)
|
||||
db.add(cmd)
|
||||
db.flush()
|
||||
return cmd
|
||||
|
||||
|
||||
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
||||
_require_rdg_flap(event)
|
||||
cfg = _require_win_admin(db)
|
||||
client_host = _resolve_client(db, event)
|
||||
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
internal_ip = event_internal_ip(event)
|
||||
|
||||
result, attempts = run_winrm_on_host_targets(
|
||||
client_host,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
action=lambda target: run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password),
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
params = {
|
||||
"execution": "winrm",
|
||||
"user": user,
|
||||
"internal_ip": internal_ip,
|
||||
"client_hostname": client_host.hostname,
|
||||
"client_host_id": client_host.id,
|
||||
"winrm_target": result.target,
|
||||
}
|
||||
return _persist_command(
|
||||
db,
|
||||
client_host=client_host,
|
||||
event=event,
|
||||
command_type="qwinsta",
|
||||
params=params,
|
||||
requested_by=requested_by,
|
||||
result=result,
|
||||
attempts=attempts,
|
||||
)
|
||||
|
||||
|
||||
def execute_logoff_via_winrm(
|
||||
db: Session,
|
||||
event: Event,
|
||||
*,
|
||||
session_id: int,
|
||||
requested_by: str,
|
||||
) -> AgentCommand:
|
||||
_require_rdg_flap(event)
|
||||
cfg = _require_win_admin(db)
|
||||
client_host = _resolve_client(db, event)
|
||||
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
internal_ip = event_internal_ip(event)
|
||||
|
||||
result, attempts = run_winrm_on_host_targets(
|
||||
client_host,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
action=lambda target: run_winrm_logoff(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
session_id=session_id,
|
||||
),
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
params = {
|
||||
"execution": "winrm",
|
||||
"user": user,
|
||||
"internal_ip": internal_ip,
|
||||
"client_hostname": client_host.hostname,
|
||||
"client_host_id": client_host.id,
|
||||
"session_id": session_id,
|
||||
"winrm_target": result.target,
|
||||
}
|
||||
return _persist_command(
|
||||
db,
|
||||
client_host=client_host,
|
||||
event=event,
|
||||
command_type="logoff",
|
||||
params=params,
|
||||
requested_by=requested_by,
|
||||
result=result,
|
||||
attempts=attempts,
|
||||
)
|
||||
@@ -28,6 +28,137 @@ class WinRmTestResult:
|
||||
hostname: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WinRmCmdResult:
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int | None = None
|
||||
|
||||
|
||||
def _winrm_session(
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
*,
|
||||
timeout_sec: int = 15,
|
||||
):
|
||||
from app.services.win_admin_settings import normalize_win_admin_user
|
||||
|
||||
user = normalize_win_admin_user(user.strip())
|
||||
target = target.strip()
|
||||
if not target or not user or not password:
|
||||
raise WinAdminNotConfiguredError("Windows admin credentials or target missing")
|
||||
|
||||
try:
|
||||
import winrm
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("pywinrm is not installed on SAC server") from exc
|
||||
|
||||
operation_timeout_sec = max(5, timeout_sec)
|
||||
read_timeout_sec = operation_timeout_sec + 15
|
||||
endpoint = f"http://{target}:5985/wsman"
|
||||
session = winrm.Session(
|
||||
endpoint,
|
||||
auth=(user, password),
|
||||
transport="ntlm",
|
||||
read_timeout_sec=read_timeout_sec,
|
||||
operation_timeout_sec=operation_timeout_sec,
|
||||
)
|
||||
return session, winrm
|
||||
|
||||
|
||||
def run_winrm_cmd(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
remote_cmd: str,
|
||||
timeout_sec: int = 30,
|
||||
) -> WinRmCmdResult:
|
||||
target = target.strip()
|
||||
try:
|
||||
session, winrm = _winrm_session(target, user, password, timeout_sec=timeout_sec)
|
||||
result = session.run_cmd(remote_cmd)
|
||||
except winrm.exceptions.WinRMTransportError as exc:
|
||||
detail = str(exc)
|
||||
hint = ""
|
||||
if "credentials were rejected" in detail.lower():
|
||||
hint = " Проверьте логин (B26\\user), пароль и WinRM на ПК."
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"WinRM transport error ({target}): {detail}{hint}",
|
||||
target=target,
|
||||
)
|
||||
except (socket.timeout, TimeoutError):
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"WinRM timeout ({timeout_sec}s) to {target}:5985",
|
||||
target=target,
|
||||
)
|
||||
except WinAdminNotConfiguredError as exc:
|
||||
return WinRmCmdResult(ok=False, message=str(exc), target=target)
|
||||
except RuntimeError as exc:
|
||||
return WinRmCmdResult(ok=False, message=str(exc), target=target)
|
||||
except Exception as exc:
|
||||
return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target)
|
||||
|
||||
stdout = (result.std_out or b"").decode("utf-8", errors="replace")
|
||||
stderr = (result.std_err or b"").decode("utf-8", errors="replace")
|
||||
exit_code = int(result.status_code)
|
||||
ok = exit_code == 0
|
||||
if ok:
|
||||
message = f"WinRM OK ({target}), exit 0"
|
||||
else:
|
||||
detail = stderr.strip() or stdout.strip() or f"exit code {exit_code}"
|
||||
message = f"WinRM command failed ({target}): {detail[:500]}"
|
||||
return WinRmCmdResult(
|
||||
ok=ok,
|
||||
message=message,
|
||||
target=target,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
|
||||
|
||||
def run_winrm_qwinsta(*, target: str, user: str, password: str) -> WinRmCmdResult:
|
||||
return run_winrm_cmd(target=target, user=user, password=password, remote_cmd="qwinsta", timeout_sec=45)
|
||||
|
||||
|
||||
def run_winrm_logoff(*, target: str, user: str, password: str, session_id: int) -> WinRmCmdResult:
|
||||
if session_id < 0:
|
||||
return WinRmCmdResult(ok=False, message="Invalid session_id", target=target)
|
||||
return run_winrm_cmd(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=f"logoff {session_id} /v",
|
||||
timeout_sec=45,
|
||||
)
|
||||
|
||||
|
||||
def run_winrm_on_host_targets(
|
||||
host: Host,
|
||||
*,
|
||||
user: str,
|
||||
password: str,
|
||||
action,
|
||||
) -> tuple[WinRmCmdResult | None, list[str]]:
|
||||
"""Try WinRM targets in hostname-first order; action(target) -> WinRmCmdResult."""
|
||||
attempts: list[str] = []
|
||||
last: WinRmCmdResult | None = None
|
||||
for target in iter_winrm_targets(host):
|
||||
result = action(target=target)
|
||||
last = result
|
||||
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
|
||||
if result.ok:
|
||||
return result, attempts
|
||||
return last, attempts
|
||||
|
||||
|
||||
def resolve_windows_host_target(host: Host) -> str:
|
||||
targets = iter_winrm_targets(host)
|
||||
if not targets:
|
||||
@@ -100,70 +231,24 @@ def test_winrm_connection(
|
||||
password: str,
|
||||
timeout_sec: int = 15,
|
||||
) -> WinRmTestResult:
|
||||
target = target.strip()
|
||||
user = user.strip()
|
||||
if not target or not user or not password:
|
||||
raise WinAdminNotConfiguredError("Windows admin credentials or target missing")
|
||||
|
||||
from app.services.win_admin_settings import normalize_win_admin_user
|
||||
|
||||
user = normalize_win_admin_user(user)
|
||||
|
||||
try:
|
||||
import winrm
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("pywinrm is not installed on SAC server") from exc
|
||||
|
||||
operation_timeout_sec = max(5, timeout_sec)
|
||||
read_timeout_sec = operation_timeout_sec + 15
|
||||
endpoint = f"http://{target}:5985/wsman"
|
||||
try:
|
||||
session = winrm.Session(
|
||||
endpoint,
|
||||
auth=(user, password),
|
||||
transport="ntlm",
|
||||
read_timeout_sec=read_timeout_sec,
|
||||
operation_timeout_sec=operation_timeout_sec,
|
||||
)
|
||||
result = session.run_cmd("hostname")
|
||||
except winrm.exceptions.WinRMTransportError as exc:
|
||||
detail = str(exc)
|
||||
hint = ""
|
||||
if "credentials were rejected" in detail.lower():
|
||||
hint = (
|
||||
" Проверьте логин (B26\\user), пароль и права admin на ПК; "
|
||||
"подключайтесь по имени хоста (как Enter-PSSession -ComputerName), не по IP."
|
||||
)
|
||||
return WinRmTestResult(
|
||||
ok=False,
|
||||
message=f"WinRM transport error ({target}): {detail}{hint}",
|
||||
target=target,
|
||||
)
|
||||
except (socket.timeout, TimeoutError):
|
||||
return WinRmTestResult(
|
||||
ok=False,
|
||||
message=f"WinRM timeout ({timeout_sec}s) to {target}:5985",
|
||||
target=target,
|
||||
)
|
||||
except Exception as exc:
|
||||
return WinRmTestResult(
|
||||
ok=False,
|
||||
message=f"WinRM error: {exc}",
|
||||
target=target,
|
||||
)
|
||||
|
||||
stdout = (result.std_out or b"").decode("utf-8", errors="replace").strip()
|
||||
stderr = (result.std_err or b"").decode("utf-8", errors="replace").strip()
|
||||
if result.status_code == 0 and stdout:
|
||||
result = run_winrm_cmd(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd="hostname",
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
if result.ok and result.stdout.strip():
|
||||
host = result.stdout.strip().splitlines()[0]
|
||||
return WinRmTestResult(
|
||||
ok=True,
|
||||
message=f"WinRM OK, hostname={stdout}",
|
||||
target=target,
|
||||
hostname=stdout,
|
||||
message=f"WinRM OK, hostname={host}",
|
||||
target=result.target,
|
||||
hostname=host,
|
||||
)
|
||||
detail = stderr or stdout or f"exit code {result.status_code}"
|
||||
return WinRmTestResult(
|
||||
ok=False,
|
||||
message=f"WinRM command failed: {detail}",
|
||||
target=target,
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target,
|
||||
hostname=None,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
|
||||
APP_NAME = "Security Alert Center"
|
||||
APP_VERSION = "0.11.5"
|
||||
APP_VERSION = "0.11.6"
|
||||
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.11.5"
|
||||
assert APP_VERSION == "0.11.6"
|
||||
assert APP_NAME == "Security Alert Center"
|
||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.11.5"
|
||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.11.6"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for RDG client workstation lookup."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import Host
|
||||
from app.services.rdg_client_host import find_windows_host_by_ipv4, resolve_client_workstation
|
||||
|
||||
|
||||
def test_find_windows_host_by_ipv4(db_session):
|
||||
host = Host(
|
||||
hostname="Andrisonova-PC",
|
||||
os_family="windows",
|
||||
product="rdp-login-monitor",
|
||||
ipv4="192.168.160.113",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
|
||||
found = find_windows_host_by_ipv4(db_session, "192.168.160.113")
|
||||
assert found is not None
|
||||
assert found.hostname == "Andrisonova-PC"
|
||||
|
||||
|
||||
def test_resolve_client_workstation_from_event(db_session):
|
||||
from app.models import Event
|
||||
|
||||
ws = Host(
|
||||
hostname="Andrisonova-PC",
|
||||
os_family="windows",
|
||||
product="rdp-login-monitor",
|
||||
ipv4="192.168.160.113",
|
||||
)
|
||||
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
|
||||
db_session.add_all([ws, gw])
|
||||
db_session.commit()
|
||||
|
||||
event = Event(
|
||||
event_id="ev-1",
|
||||
host_id=gw.id,
|
||||
occurred_at=datetime(2026, 6, 20, tzinfo=timezone.utc),
|
||||
received_at=datetime(2026, 6, 20, tzinfo=timezone.utc),
|
||||
category="auth",
|
||||
type="rdg.connection.disconnected",
|
||||
severity="info",
|
||||
title="303",
|
||||
summary="",
|
||||
payload={},
|
||||
details={"user": r"B26\user", "internal_ip": "192.168.160.113", "rdg_flap": True},
|
||||
)
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
|
||||
client = resolve_client_workstation(db_session, event)
|
||||
assert client.id == ws.id
|
||||
assert client.hostname == "Andrisonova-PC"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""API tests for RDG qwinsta/logoff via WinRM on client workstation."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.winrm_connect import WinRmCmdResult
|
||||
|
||||
|
||||
def _flap_event(db_session, *, gw: Host, ws: Host) -> Event:
|
||||
event = Event(
|
||||
event_id="ev-flap-1",
|
||||
host_id=gw.id,
|
||||
occurred_at=datetime.now(timezone.utc),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
category="auth",
|
||||
type="rdg.connection.disconnected",
|
||||
severity="info",
|
||||
title="RD Gateway event 303",
|
||||
summary="",
|
||||
payload={},
|
||||
details={
|
||||
"user": r"B26\papatramp",
|
||||
"internal_ip": ws.ipv4,
|
||||
"rdg_flap": True,
|
||||
},
|
||||
)
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
db_session.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monkeypatch):
|
||||
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
|
||||
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
ws = Host(
|
||||
hostname="Andrisonova-PC",
|
||||
os_family="windows",
|
||||
product="rdp-login-monitor",
|
||||
ipv4="192.168.160.113",
|
||||
)
|
||||
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
|
||||
db_session.add_all([ws, gw])
|
||||
db_session.commit()
|
||||
event = _flap_event(db_session, gw=gw, ws=ws)
|
||||
|
||||
qwinsta_out = " SESSIONNAME USERNAME ID STATE\r\n rdp-tcp#0 B26\\papatramp 2 Active\r\n"
|
||||
|
||||
with patch("app.services.rdg_winrm_actions.run_winrm_on_host_targets") as mock_run:
|
||||
mock_run.return_value = (
|
||||
WinRmCmdResult(
|
||||
ok=True,
|
||||
message="WinRM OK (Andrisonova-PC), exit 0",
|
||||
target="Andrisonova-PC",
|
||||
stdout=qwinsta_out,
|
||||
exit_code=0,
|
||||
),
|
||||
["Andrisonova-PC=OK"],
|
||||
)
|
||||
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "completed"
|
||||
assert body["client_hostname"] == "Andrisonova-PC"
|
||||
assert body["target"] == "Andrisonova-PC"
|
||||
assert "papatramp" in (body["result_stdout"] or "")
|
||||
|
||||
mock_run.assert_called_once()
|
||||
called_host = mock_run.call_args.args[0]
|
||||
assert called_host.hostname == "Andrisonova-PC"
|
||||
|
||||
|
||||
def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatch):
|
||||
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
|
||||
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
|
||||
db_session.add(gw)
|
||||
db_session.commit()
|
||||
|
||||
event = Event(
|
||||
event_id="ev-flap-2",
|
||||
host_id=gw.id,
|
||||
occurred_at=datetime.now(timezone.utc),
|
||||
received_at=datetime.now(timezone.utc),
|
||||
category="auth",
|
||||
type="rdg.connection.disconnected",
|
||||
severity="info",
|
||||
title="303",
|
||||
summary="",
|
||||
payload={},
|
||||
details={"user": r"B26\user", "internal_ip": "192.168.160.999", "rdg_flap": True},
|
||||
)
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
@@ -180,6 +180,9 @@ export interface AgentCommandResponse {
|
||||
result_stderr?: string | null;
|
||||
created_at?: string | null;
|
||||
completed_at?: string | null;
|
||||
target?: string | null;
|
||||
client_hostname?: string | null;
|
||||
internal_ip?: string | null;
|
||||
}
|
||||
|
||||
export function postEventQwinsta(eventId: number): Promise<AgentCommandResponse> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||
export const APP_NAME = "Security Alert Center";
|
||||
export const APP_VERSION = "0.11.5";
|
||||
export const APP_VERSION = "0.11.6";
|
||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||
|
||||
@@ -305,7 +305,8 @@
|
||||
|
||||
<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>
|
||||
<h3>qwinsta — {{ qwinstaModal.clientHostname || qwinstaModal.target || `#${qwinstaModal.eventId}` }}</h3>
|
||||
<p v-if="qwinstaModal.internalIp" class="muted qwinsta-meta">Клиентский ПК: {{ qwinstaModal.internalIp }}</p>
|
||||
<p v-if="qwinstaModal.error" class="error">{{ qwinstaModal.error }}</p>
|
||||
<p v-else-if="qwinstaModal.loading">Ожидание ответа агента…</p>
|
||||
<template v-else>
|
||||
@@ -412,6 +413,9 @@ const qwinstaModal = ref({
|
||||
eventId: 0,
|
||||
actorUser: "",
|
||||
commandUuid: "",
|
||||
target: "",
|
||||
clientHostname: "",
|
||||
internalIp: "",
|
||||
loading: false,
|
||||
error: "",
|
||||
stdout: "",
|
||||
@@ -494,6 +498,12 @@ function pollQwinstaCommand(eventId: number, commandUuid: string, actorUser: str
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function applyCommandMeta(modal: typeof qwinstaModal.value, cmd: AgentCommandResponse) {
|
||||
modal.target = cmd.target || "";
|
||||
modal.clientHostname = cmd.client_hostname || "";
|
||||
modal.internalIp = cmd.internal_ip || "";
|
||||
}
|
||||
|
||||
async function runQwinsta(event: EventSummary) {
|
||||
qwinstaLoadingId.value = event.id;
|
||||
qwinstaModal.value = {
|
||||
@@ -501,6 +511,9 @@ async function runQwinsta(event: EventSummary) {
|
||||
eventId: event.id,
|
||||
actorUser: event.actor_user || "",
|
||||
commandUuid: "",
|
||||
target: "",
|
||||
clientHostname: "",
|
||||
internalIp: "",
|
||||
loading: true,
|
||||
error: "",
|
||||
stdout: "",
|
||||
@@ -509,6 +522,7 @@ async function runQwinsta(event: EventSummary) {
|
||||
};
|
||||
try {
|
||||
const cmd = await postEventQwinsta(event.id);
|
||||
applyCommandMeta(qwinstaModal.value, cmd);
|
||||
qwinstaModal.value.commandUuid = cmd.command_uuid;
|
||||
if (applyCommandResult(event.id, event.actor_user || "", cmd)) {
|
||||
return;
|
||||
@@ -530,25 +544,20 @@ async function runLogoff(sessionId: number) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
applyCommandMeta(modal, cmd);
|
||||
if (cmd.status === "failed") {
|
||||
modal.error = cmd.result_stderr || cmd.result_stdout || "logoff failed";
|
||||
return;
|
||||
}
|
||||
modal.loading = true;
|
||||
modal.stdout = "";
|
||||
modal.sessions = [];
|
||||
const again = await postEventQwinsta(modal.eventId);
|
||||
applyCommandMeta(modal, again);
|
||||
if (applyCommandResult(modal.eventId, modal.actorUser, again)) {
|
||||
return;
|
||||
}
|
||||
pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser);
|
||||
} catch (e) {
|
||||
modal.error = e instanceof Error ? e.message : "logoff error";
|
||||
} finally {
|
||||
@@ -849,6 +858,11 @@ onUnmounted(() => {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.qwinsta-meta {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.qwinsta-raw {
|
||||
font-size: 0.8rem;
|
||||
max-height: 12rem;
|
||||
|
||||
Reference in New Issue
Block a user