feat: Windows admin in Settings UI and WinRM host test (0.10.1)
Store domain admin in ui_settings (DB overrides env); qwinsta/logoff use effective config.
Host card: POST /hosts/{id}/actions/winrm-test via pywinrm.
This commit is contained in:
@@ -12,20 +12,20 @@ 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.services.win_admin_settings import get_effective_win_admin_config
|
||||
|
||||
|
||||
def _win_admin_configured() -> bool:
|
||||
settings = get_settings()
|
||||
return bool(settings.sac_win_admin_user.strip() and settings.sac_win_admin_password.strip())
|
||||
return get_effective_win_admin_config().configured
|
||||
|
||||
|
||||
def win_admin_run_as() -> dict[str, str] | None:
|
||||
if not _win_admin_configured():
|
||||
cfg = get_effective_win_admin_config()
|
||||
if not cfg.configured:
|
||||
return None
|
||||
settings = get_settings()
|
||||
return {
|
||||
"user": settings.sac_win_admin_user.strip(),
|
||||
"password": settings.sac_win_admin_password,
|
||||
"user": cfg.user,
|
||||
"password": cfg.password,
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ def queue_qwinsta(db: Session, event: Event, *, requested_by: str) -> AgentComma
|
||||
if not _win_admin_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="SAC_WIN_ADMIN_USER / SAC_WIN_ADMIN_PASSWORD not configured",
|
||||
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")
|
||||
@@ -83,7 +83,7 @@ def queue_logoff(
|
||||
if not _win_admin_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="SAC_WIN_ADMIN_USER / SAC_WIN_ADMIN_PASSWORD not configured",
|
||||
detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
|
||||
)
|
||||
cmd = AgentCommand(
|
||||
command_uuid=str(uuid.uuid4()),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Effective Windows domain admin credentials (DB overrides env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WinAdminConfig:
|
||||
user: str
|
||||
password: str
|
||||
source: str # env | db
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.user.strip() and self.password.strip())
|
||||
|
||||
|
||||
def _win_admin_from_env() -> WinAdminConfig:
|
||||
settings = get_settings()
|
||||
return WinAdminConfig(
|
||||
user=settings.sac_win_admin_user.strip(),
|
||||
password=settings.sac_win_admin_password,
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def get_effective_win_admin_config(db: Session | None = None) -> WinAdminConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_win_admin_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_cfg = _win_admin_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
user = (row.win_admin_user or "").strip() or env_cfg.user
|
||||
password = (row.win_admin_password or "") or env_cfg.password
|
||||
has_db_values = bool((row.win_admin_user or "").strip() or (row.win_admin_password or "").strip())
|
||||
return WinAdminConfig(
|
||||
user=user,
|
||||
password=password,
|
||||
source="db" if has_db_values else env_cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def upsert_win_admin_settings(
|
||||
db: Session,
|
||||
*,
|
||||
user: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> WinAdminConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_cfg = _win_admin_from_env()
|
||||
if row is None:
|
||||
row = UiSettings(
|
||||
id=UI_SETTINGS_ROW_ID,
|
||||
show_sidebar_system_stats=True,
|
||||
win_admin_user=env_cfg.user or None,
|
||||
win_admin_password=env_cfg.password or None,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
if user is not None and user.strip():
|
||||
row.win_admin_user = user.strip()
|
||||
if password is not None and password.strip():
|
||||
row.win_admin_password = password
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_win_admin_config(db)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""WinRM connectivity test for Windows hosts using domain admin credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.models import Host
|
||||
|
||||
|
||||
class WinAdminNotConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HostNotWindowsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HostTargetMissingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WinRmTestResult:
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
hostname: str | None = None
|
||||
|
||||
|
||||
def resolve_windows_host_target(host: Host) -> str:
|
||||
if not is_windows_host(host):
|
||||
raise HostNotWindowsError("Host is not Windows")
|
||||
target = (host.ipv4 or "").strip() or (host.hostname or "").strip()
|
||||
if not target:
|
||||
raise HostTargetMissingError("Host has no IPv4 or hostname for WinRM test")
|
||||
return target
|
||||
|
||||
|
||||
def is_windows_host(host: Host) -> bool:
|
||||
if (host.os_family or "").strip().lower() == "windows":
|
||||
return True
|
||||
return (host.product or "").strip() == "rdp-login-monitor"
|
||||
|
||||
|
||||
def test_winrm_connection(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
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")
|
||||
|
||||
try:
|
||||
import winrm
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("pywinrm is not installed on SAC server") from exc
|
||||
|
||||
endpoint = f"http://{target}:5985/wsman"
|
||||
try:
|
||||
session = winrm.Session(
|
||||
endpoint,
|
||||
auth=(user, password),
|
||||
transport="ntlm",
|
||||
read_timeout_sec=timeout_sec,
|
||||
operation_timeout_sec=timeout_sec,
|
||||
)
|
||||
result = session.run_cmd("hostname")
|
||||
except winrm.exceptions.WinRMTransportError as exc:
|
||||
return WinRmTestResult(
|
||||
ok=False,
|
||||
message=f"WinRM transport error: {exc}",
|
||||
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:
|
||||
return WinRmTestResult(
|
||||
ok=True,
|
||||
message=f"WinRM OK, hostname={stdout}",
|
||||
target=target,
|
||||
hostname=stdout,
|
||||
)
|
||||
detail = stderr or stdout or f"exit code {result.status_code}"
|
||||
return WinRmTestResult(
|
||||
ok=False,
|
||||
message=f"WinRM command failed: {detail}",
|
||||
target=target,
|
||||
)
|
||||
Reference in New Issue
Block a user