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.models import Event, Host
|
||||||
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
||||||
from app.services.agent_commands import (
|
from app.services.agent_commands import (
|
||||||
|
command_response_fields,
|
||||||
command_to_dict,
|
command_to_dict,
|
||||||
get_command_by_uuid,
|
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.ingest import ingest_event
|
||||||
from app.services.event_summary import event_to_summary
|
from app.services.event_summary import event_to_summary
|
||||||
from app.services.problems import maybe_create_problem
|
from app.services.problems import maybe_create_problem
|
||||||
@@ -182,6 +182,26 @@ class AgentCommandResponse(BaseModel):
|
|||||||
result_stderr: str | None = None
|
result_stderr: str | None = None
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
completed_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):
|
class LogoffActionBody(BaseModel):
|
||||||
@@ -197,18 +217,9 @@ def post_event_qwinsta(
|
|||||||
event = db.get(Event, event_db_id)
|
event = db.get(Event, event_db_id)
|
||||||
if event is None:
|
if event is None:
|
||||||
raise HTTPException(status_code=404, detail="Event not found")
|
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()
|
db.commit()
|
||||||
data = command_to_dict(cmd)
|
return _agent_command_response(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)
|
@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)
|
event = db.get(Event, event_db_id)
|
||||||
if event is None:
|
if event is None:
|
||||||
raise HTTPException(status_code=404, detail="Event not found")
|
raise HTTPException(status_code=404, detail="Event not found")
|
||||||
cmd = queue_logoff(
|
cmd = execute_logoff_via_winrm(
|
||||||
db,
|
db,
|
||||||
event,
|
event,
|
||||||
session_id=body.session_id,
|
session_id=body.session_id,
|
||||||
requested_by=str(user),
|
requested_by=str(user),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
data = command_to_dict(cmd)
|
return _agent_command_response(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)
|
@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)
|
cmd = get_command_by_uuid(db, command_uuid)
|
||||||
if cmd is None or cmd.event_id != event_db_id:
|
if cmd is None or cmd.event_id != event_db_id:
|
||||||
raise HTTPException(status_code=404, detail="Command not found")
|
raise HTTPException(status_code=404, detail="Command not found")
|
||||||
data = command_to_dict(cmd)
|
return _agent_command_response(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)
|
@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
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models import AgentCommand, Event, Host
|
from app.models import AgentCommand, Host
|
||||||
from app.services.rdg_session_flap import event_has_rdg_flap
|
|
||||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
def queue_qwinsta(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
def command_response_fields(cmd: AgentCommand) -> dict:
|
||||||
if not event_has_rdg_flap(event):
|
params = cmd.params if isinstance(cmd.params, dict) else {}
|
||||||
raise HTTPException(status_code=400, detail="Event is not flagged as RDG session flap")
|
return {
|
||||||
if not _win_admin_configured():
|
"target": params.get("winrm_target") or params.get("client_hostname"),
|
||||||
raise HTTPException(
|
"client_hostname": params.get("client_hostname"),
|
||||||
status_code=503,
|
"internal_ip": params.get("internal_ip"),
|
||||||
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 get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]:
|
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 ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def event_internal_ip(event: Event) -> str:
|
||||||
|
return _event_internal_ip(event)
|
||||||
|
|
||||||
|
|
||||||
def _users_match(end_event: Event, success_event: Event) -> bool:
|
def _users_match(end_event: Event, success_event: Event) -> bool:
|
||||||
return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event)
|
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
|
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:
|
def resolve_windows_host_target(host: Host) -> str:
|
||||||
targets = iter_winrm_targets(host)
|
targets = iter_winrm_targets(host)
|
||||||
if not targets:
|
if not targets:
|
||||||
@@ -100,70 +231,24 @@ def test_winrm_connection(
|
|||||||
password: str,
|
password: str,
|
||||||
timeout_sec: int = 15,
|
timeout_sec: int = 15,
|
||||||
) -> WinRmTestResult:
|
) -> WinRmTestResult:
|
||||||
target = target.strip()
|
result = run_winrm_cmd(
|
||||||
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,
|
target=target,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
remote_cmd="hostname",
|
||||||
|
timeout_sec=timeout_sec,
|
||||||
)
|
)
|
||||||
except (socket.timeout, TimeoutError):
|
if result.ok and result.stdout.strip():
|
||||||
return WinRmTestResult(
|
host = result.stdout.strip().splitlines()[0]
|
||||||
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:
|
|
||||||
return WinRmTestResult(
|
return WinRmTestResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
message=f"WinRM OK, hostname={stdout}",
|
message=f"WinRM OK, hostname={host}",
|
||||||
target=target,
|
target=result.target,
|
||||||
hostname=stdout,
|
hostname=host,
|
||||||
)
|
)
|
||||||
detail = stderr or stdout or f"exit code {result.status_code}"
|
|
||||||
return WinRmTestResult(
|
return WinRmTestResult(
|
||||||
ok=False,
|
ok=result.ok,
|
||||||
message=f"WinRM command failed: {detail}",
|
message=result.message,
|
||||||
target=target,
|
target=result.target,
|
||||||
|
hostname=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.11.5"
|
APP_VERSION = "0.11.6"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
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():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.11.5"
|
assert APP_VERSION == "0.11.6"
|
||||||
assert APP_NAME == "Security Alert Center"
|
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;
|
result_stderr?: string | null;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
completed_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> {
|
export function postEventQwinsta(eventId: number): Promise<AgentCommandResponse> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
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}`;
|
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 v-if="qwinstaModal.open" class="modal-backdrop" @click.self="closeQwinstaModal">
|
||||||
<div class="modal-card qwinsta-modal" role="dialog" aria-modal="true">
|
<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-if="qwinstaModal.error" class="error">{{ qwinstaModal.error }}</p>
|
||||||
<p v-else-if="qwinstaModal.loading">Ожидание ответа агента…</p>
|
<p v-else-if="qwinstaModal.loading">Ожидание ответа агента…</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -412,6 +413,9 @@ const qwinstaModal = ref({
|
|||||||
eventId: 0,
|
eventId: 0,
|
||||||
actorUser: "",
|
actorUser: "",
|
||||||
commandUuid: "",
|
commandUuid: "",
|
||||||
|
target: "",
|
||||||
|
clientHostname: "",
|
||||||
|
internalIp: "",
|
||||||
loading: false,
|
loading: false,
|
||||||
error: "",
|
error: "",
|
||||||
stdout: "",
|
stdout: "",
|
||||||
@@ -494,6 +498,12 @@ function pollQwinstaCommand(eventId: number, commandUuid: string, actorUser: str
|
|||||||
}, 2000);
|
}, 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) {
|
async function runQwinsta(event: EventSummary) {
|
||||||
qwinstaLoadingId.value = event.id;
|
qwinstaLoadingId.value = event.id;
|
||||||
qwinstaModal.value = {
|
qwinstaModal.value = {
|
||||||
@@ -501,6 +511,9 @@ async function runQwinsta(event: EventSummary) {
|
|||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
actorUser: event.actor_user || "",
|
actorUser: event.actor_user || "",
|
||||||
commandUuid: "",
|
commandUuid: "",
|
||||||
|
target: "",
|
||||||
|
clientHostname: "",
|
||||||
|
internalIp: "",
|
||||||
loading: true,
|
loading: true,
|
||||||
error: "",
|
error: "",
|
||||||
stdout: "",
|
stdout: "",
|
||||||
@@ -509,6 +522,7 @@ async function runQwinsta(event: EventSummary) {
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const cmd = await postEventQwinsta(event.id);
|
const cmd = await postEventQwinsta(event.id);
|
||||||
|
applyCommandMeta(qwinstaModal.value, cmd);
|
||||||
qwinstaModal.value.commandUuid = cmd.command_uuid;
|
qwinstaModal.value.commandUuid = cmd.command_uuid;
|
||||||
if (applyCommandResult(event.id, event.actor_user || "", cmd)) {
|
if (applyCommandResult(event.id, event.actor_user || "", cmd)) {
|
||||||
return;
|
return;
|
||||||
@@ -530,25 +544,20 @@ async function runLogoff(sessionId: number) {
|
|||||||
modal.error = "";
|
modal.error = "";
|
||||||
try {
|
try {
|
||||||
const cmd = await postEventLogoff(modal.eventId, sessionId);
|
const cmd = await postEventLogoff(modal.eventId, sessionId);
|
||||||
if (cmd.status === "pending") {
|
applyCommandMeta(modal, cmd);
|
||||||
const deadline = Date.now() + 60000;
|
if (cmd.status === "failed") {
|
||||||
while (Date.now() < deadline) {
|
modal.error = cmd.result_stderr || cmd.result_stdout || "logoff failed";
|
||||||
await new Promise((r) => setTimeout(r, 2000));
|
return;
|
||||||
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.loading = true;
|
||||||
modal.stdout = "";
|
modal.stdout = "";
|
||||||
modal.sessions = [];
|
modal.sessions = [];
|
||||||
const again = await postEventQwinsta(modal.eventId);
|
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);
|
pollQwinstaCommand(modal.eventId, again.command_uuid, modal.actorUser);
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
modal.error = e instanceof Error ? e.message : "logoff error";
|
modal.error = e instanceof Error ? e.message : "logoff error";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -849,6 +858,11 @@ onUnmounted(() => {
|
|||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qwinsta-meta {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.qwinsta-raw {
|
.qwinsta-raw {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
max-height: 12rem;
|
max-height: 12rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user