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:
@@ -0,0 +1,25 @@
|
||||
"""ui_settings: Windows domain admin for agent commands
|
||||
|
||||
Revision ID: 017
|
||||
Revises: 016
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "017"
|
||||
down_revision: Union[str, None] = "016"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("ui_settings", sa.Column("win_admin_user", sa.String(256), nullable=True))
|
||||
op.add_column("ui_settings", sa.Column("win_admin_password", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ui_settings", "win_admin_password")
|
||||
op.drop_column("ui_settings", "win_admin_user")
|
||||
@@ -10,6 +10,14 @@ from app.models import Event, Host
|
||||
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
||||
from app.services.agent_version import latest_agent_versions_by_product
|
||||
from app.services.host_delete import delete_host_and_related
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError,
|
||||
WinAdminNotConfiguredError,
|
||||
resolve_windows_host_target,
|
||||
test_winrm_connection,
|
||||
)
|
||||
from app.services.host_health import (
|
||||
HEARTBEAT_TYPE,
|
||||
agent_status as compute_agent_status,
|
||||
@@ -156,6 +164,53 @@ class HostDeleteResponse(BaseModel):
|
||||
deleted_problems: int
|
||||
|
||||
|
||||
class HostWinRmTestResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
hostname: str | None = None
|
||||
|
||||
|
||||
@router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse)
|
||||
def test_host_winrm(
|
||||
host_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> HostWinRmTestResponse:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
raise HTTPException(status_code=404, detail="Host not found")
|
||||
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
|
||||
|
||||
try:
|
||||
target = resolve_windows_host_target(host)
|
||||
except HostNotWindowsError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except HostTargetMissingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
try:
|
||||
result = test_winrm_connection(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
)
|
||||
except WinAdminNotConfiguredError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
return HostWinRmTestResponse(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target,
|
||||
hostname=result.hostname,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{host_id}", response_model=HostDeleteResponse)
|
||||
def delete_host(
|
||||
host_id: int,
|
||||
|
||||
@@ -37,6 +37,17 @@ from app.services.ui_settings import (
|
||||
get_effective_ui_settings,
|
||||
upsert_ui_settings,
|
||||
)
|
||||
from app.services.win_admin_settings import (
|
||||
get_effective_win_admin_config,
|
||||
upsert_win_admin_settings,
|
||||
)
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError,
|
||||
WinAdminNotConfiguredError,
|
||||
resolve_windows_host_target,
|
||||
test_winrm_connection,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
@@ -439,3 +450,46 @@ def update_ui_settings(
|
||||
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
class WinAdminSettingsResponse(BaseModel):
|
||||
configured: bool
|
||||
user: str | None = None
|
||||
password_hint: str | None = None
|
||||
source: str = Field(description="env или db")
|
||||
|
||||
|
||||
class WinAdminSettingsUpdate(BaseModel):
|
||||
user: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
@router.get("/win-admin", response_model=WinAdminSettingsResponse)
|
||||
def get_win_admin_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> WinAdminSettingsResponse:
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
return WinAdminSettingsResponse(
|
||||
configured=cfg.configured,
|
||||
user=cfg.user or None,
|
||||
password_hint=_mask_secret(cfg.password),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/win-admin", response_model=WinAdminSettingsResponse)
|
||||
def update_win_admin_settings(
|
||||
body: WinAdminSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_user=Depends(require_admin),
|
||||
) -> WinAdminSettingsResponse:
|
||||
if body.user is not None and not body.user.strip():
|
||||
raise HTTPException(status_code=422, detail="user must not be empty")
|
||||
cfg = upsert_win_admin_settings(db, user=body.user, password=body.password)
|
||||
return WinAdminSettingsResponse(
|
||||
configured=cfg.configured,
|
||||
user=cfg.user or None,
|
||||
password_hint=_mask_secret(cfg.password),
|
||||
source=cfg.source,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Boolean
|
||||
from sqlalchemy import Boolean, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
@@ -13,3 +13,5 @@ class UiSettings(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
show_sidebar_system_stats: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
win_admin_user: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
win_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
|
||||
APP_NAME = "Security Alert Center"
|
||||
APP_VERSION = "0.10.0"
|
||||
APP_VERSION = "0.10.1"
|
||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||
|
||||
@@ -13,6 +13,7 @@ httpx>=0.28.0,<1
|
||||
google-auth>=2.36.0,<3
|
||||
requests>=2.32.0,<3
|
||||
psutil>=6.1.0,<8
|
||||
pywinrm>=0.5.0,<1
|
||||
|
||||
# dev
|
||||
pytest>=8.3.0,<9
|
||||
|
||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
||||
|
||||
|
||||
def test_version_constants():
|
||||
assert APP_VERSION == "0.10.0"
|
||||
assert APP_VERSION == "0.10.1"
|
||||
assert APP_NAME == "Security Alert Center"
|
||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.10.0"
|
||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.10.1"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Win admin settings and WinRM host test."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
|
||||
def test_get_win_admin_settings_env_default(jwt_headers, client, monkeypatch):
|
||||
monkeypatch.setenv("SAC_WIN_ADMIN_USER", "")
|
||||
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
response = client.get("/api/v1/settings/win-admin", headers=jwt_headers)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["configured"] is False
|
||||
assert body["source"] == "env"
|
||||
|
||||
|
||||
def test_put_win_admin_settings_persists(jwt_headers, client, db_session, monkeypatch):
|
||||
monkeypatch.setenv("SAC_WIN_ADMIN_USER", "")
|
||||
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
response = client.put(
|
||||
"/api/v1/settings/win-admin",
|
||||
headers=jwt_headers,
|
||||
json={"user": r"B26\papatramp", "password": "secret-pass"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["configured"] is True
|
||||
assert body["user"] == r"B26\papatramp"
|
||||
assert body["source"] == "db"
|
||||
assert body["password_hint"]
|
||||
|
||||
row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
assert row is not None
|
||||
assert row.win_admin_user == r"B26\papatramp"
|
||||
assert row.win_admin_password == "secret-pass"
|
||||
|
||||
|
||||
def test_host_winrm_test_requires_admin(jwt_monitor_headers, client):
|
||||
response = client.post("/api/v1/hosts/1/actions/winrm-test", headers=jwt_monitor_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_host_winrm_test_not_found(jwt_headers, client):
|
||||
response = client.post("/api/v1/hosts/99999/actions/winrm-test", headers=jwt_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_host_winrm_test_success(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()
|
||||
|
||||
from app.models import Host
|
||||
from app.services.winrm_connect import WinRmTestResult
|
||||
|
||||
host = Host(
|
||||
hostname="PC01",
|
||||
os_family="windows",
|
||||
product="rdp-login-monitor",
|
||||
ipv4="192.168.1.10",
|
||||
)
|
||||
db_session.add(host)
|
||||
db_session.commit()
|
||||
db_session.refresh(host)
|
||||
|
||||
with patch("app.api.v1.hosts.test_winrm_connection") as mock_test:
|
||||
mock_test.return_value = WinRmTestResult(
|
||||
ok=True,
|
||||
message="WinRM OK, hostname=PC01",
|
||||
target="192.168.1.10",
|
||||
hostname="PC01",
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/v1/hosts/{host.id}/actions/winrm-test",
|
||||
headers=jwt_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["ok"] is True
|
||||
assert body["hostname"] == "PC01"
|
||||
assert body["target"] == "192.168.1.10"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "sac-ui",
|
||||
"private": true,
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -412,6 +412,40 @@ export function testMobileDevicePush(deviceId: number): Promise<{ message: strin
|
||||
return apiFetch(`/api/v1/mobile/devices/${deviceId}/test-push`, { method: "POST" });
|
||||
}
|
||||
|
||||
export interface WinAdminSettings {
|
||||
configured: boolean;
|
||||
user: string | null;
|
||||
password_hint: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export function fetchWinAdminSettings(): Promise<WinAdminSettings> {
|
||||
return apiFetch<WinAdminSettings>("/api/v1/settings/win-admin");
|
||||
}
|
||||
|
||||
export function updateWinAdminSettings(payload: {
|
||||
user?: string | null;
|
||||
password?: string | null;
|
||||
}): Promise<WinAdminSettings> {
|
||||
return apiFetch<WinAdminSettings>("/api/v1/settings/win-admin", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export interface HostWinRmTestResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
target: string;
|
||||
hostname: string | null;
|
||||
}
|
||||
|
||||
export function testHostWinRm(hostId: number): Promise<HostWinRmTestResult> {
|
||||
return apiFetch<HostWinRmTestResult>(`/api/v1/hosts/${hostId}/actions/winrm-test`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
events_last_24h: number;
|
||||
hosts_total: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||
export const APP_NAME = "Security Alert Center";
|
||||
export const APP_VERSION = "0.10.0";
|
||||
export const APP_VERSION = "0.10.1";
|
||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||
|
||||
@@ -47,6 +47,17 @@
|
||||
<dd>{{ host.event_count ?? 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div v-if="isWindowsHost" class="host-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
:disabled="testingWinRm"
|
||||
@click="runWinRmTest"
|
||||
>
|
||||
{{ testingWinRm ? "Проверка…" : "Проверить WinRM (admin)" }}
|
||||
</button>
|
||||
<p v-if="winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="inventory" class="host-inventory card">
|
||||
@@ -165,6 +176,7 @@ import {
|
||||
apiFetch,
|
||||
fetchHost,
|
||||
fetchRecentEvents,
|
||||
testHostWinRm,
|
||||
type EventListResponse,
|
||||
type HostDetail,
|
||||
} from "../api";
|
||||
@@ -180,6 +192,9 @@ const error = ref("");
|
||||
const eventsError = ref("");
|
||||
const eventsPage = ref(1);
|
||||
const eventsPageSize = 50;
|
||||
const testingWinRm = ref(false);
|
||||
const winRmTestMessage = ref("");
|
||||
const winRmTestOk = ref(false);
|
||||
let refreshingLive = false;
|
||||
|
||||
useSacLiveEventStream(() => {
|
||||
@@ -188,6 +203,13 @@ useSacLiveEventStream(() => {
|
||||
|
||||
const hostId = computed(() => parseInt(props.id, 10));
|
||||
|
||||
const isWindowsHost = computed(() => {
|
||||
const h = host.value;
|
||||
if (!h) return false;
|
||||
if ((h.os_family || "").toLowerCase() === "windows") return true;
|
||||
return h.product === "rdp-login-monitor";
|
||||
});
|
||||
|
||||
const inventory = computed(() => host.value?.inventory ?? null);
|
||||
|
||||
const windowsFields = computed(() => {
|
||||
@@ -266,6 +288,7 @@ function agentLabel(status: string) {
|
||||
async function loadHost() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
winRmTestMessage.value = "";
|
||||
try {
|
||||
host.value = await fetchHost(hostId.value);
|
||||
} catch (e) {
|
||||
@@ -275,6 +298,21 @@ async function loadHost() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runWinRmTest() {
|
||||
testingWinRm.value = true;
|
||||
winRmTestMessage.value = "";
|
||||
try {
|
||||
const result = await testHostWinRm(hostId.value);
|
||||
winRmTestOk.value = result.ok;
|
||||
winRmTestMessage.value = result.message;
|
||||
} catch (e) {
|
||||
winRmTestOk.value = false;
|
||||
winRmTestMessage.value = e instanceof Error ? e.message : "Ошибка проверки WinRM";
|
||||
} finally {
|
||||
testingWinRm.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFromLatestEvent() {
|
||||
if (refreshingLive) return;
|
||||
refreshingLive = true;
|
||||
|
||||
@@ -44,6 +44,44 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card settings-card settings-policy">
|
||||
<h2>Windows (qwinsta / logoff)</h2>
|
||||
<p class="settings-hint settings-hint-top">
|
||||
Доменный admin для удалённых команд на Windows-хостах (qwinsta, logoff через агент).
|
||||
Формат логина: <code>ДОМЕН\пользователь</code> (например <code>B26\papatramp</code>).
|
||||
Пока запись не создана в UI — используются <code>SAC_WIN_ADMIN_*</code> из <code>sac-api.env</code>.
|
||||
</p>
|
||||
<form class="settings-form" @submit.prevent="saveWinAdminSettings">
|
||||
<label class="settings-field">
|
||||
Доменный admin
|
||||
<input
|
||||
v-model="winAdminForm.user"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="B26\papatramp"
|
||||
/>
|
||||
</label>
|
||||
<label class="settings-field">
|
||||
Пароль
|
||||
<input
|
||||
v-model="winAdminForm.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="winAdminPasswordPlaceholder"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="winAdminLoaded" class="settings-meta">
|
||||
Настроен: {{ winAdminLoaded.configured ? "да" : "нет" }}
|
||||
· источник: <code>{{ winAdminLoaded.source }}</code>
|
||||
</p>
|
||||
<div class="settings-actions">
|
||||
<button type="submit" :disabled="savingWinAdmin">
|
||||
{{ savingWinAdmin ? "Сохранение…" : "Сохранить Windows admin" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card settings-card settings-policy">
|
||||
<h2>Правило оповещений</h2>
|
||||
<p class="settings-hint settings-hint-top">
|
||||
@@ -436,11 +474,13 @@ import {
|
||||
fetchMobileEnrollmentCodes,
|
||||
fetchMobileSettings,
|
||||
fetchUsers,
|
||||
fetchWinAdminSettings,
|
||||
revokeMobileDevice,
|
||||
deleteMobileDevice,
|
||||
revokeMobileEnrollmentCode,
|
||||
testMobileDevicePush,
|
||||
updateMobileSettings,
|
||||
updateWinAdminSettings,
|
||||
type MobileDevice,
|
||||
type MobileEnrollmentCode,
|
||||
type MobileSettings,
|
||||
@@ -495,6 +535,13 @@ interface UiSettings {
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface WinAdminSettings {
|
||||
configured: boolean;
|
||||
user: string | null;
|
||||
password_hint: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface SeverityOverrideRowApi {
|
||||
event_type: string;
|
||||
default_severity: string;
|
||||
@@ -508,6 +555,7 @@ interface SeverityOverrideRowUi extends SeverityOverrideRowApi {
|
||||
const loading = ref(true);
|
||||
const savingPolicy = ref(false);
|
||||
const savingUi = ref(false);
|
||||
const savingWinAdmin = ref(false);
|
||||
const savingTelegram = ref(false);
|
||||
const savingWebhook = ref(false);
|
||||
const savingEmail = ref(false);
|
||||
@@ -525,6 +573,7 @@ const webhookLoaded = ref<WebhookSettings | null>(null);
|
||||
const emailLoaded = ref<EmailSettings | null>(null);
|
||||
const policyLoaded = ref<NotificationPolicy | null>(null);
|
||||
const uiLoaded = ref<UiSettings | null>(null);
|
||||
const winAdminLoaded = ref<WinAdminSettings | null>(null);
|
||||
const severityRows = ref<SeverityOverrideRowUi[]>([]);
|
||||
const severityFilter = ref("");
|
||||
const mobileLoaded = ref<MobileSettings | null>(null);
|
||||
@@ -587,6 +636,11 @@ const uiForm = reactive({
|
||||
show_sidebar_system_stats: true,
|
||||
});
|
||||
|
||||
const winAdminForm = reactive({
|
||||
user: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const telegramForm = reactive({
|
||||
enabled: false,
|
||||
bot_token: "",
|
||||
@@ -637,6 +691,11 @@ const smtpPasswordPlaceholder = computed(() =>
|
||||
const mailToPlaceholder = computed(() =>
|
||||
emailLoaded.value?.mail_to_hint ? `оставить ${emailLoaded.value.mail_to_hint}` : "admin@example.com",
|
||||
);
|
||||
const winAdminPasswordPlaceholder = computed(() =>
|
||||
winAdminLoaded.value?.password_hint
|
||||
? `оставить ${winAdminLoaded.value.password_hint}`
|
||||
: "обязателен при первой настройке",
|
||||
);
|
||||
|
||||
const filteredSeverityRows = computed(() => {
|
||||
const q = severityFilter.value.trim().toLowerCase();
|
||||
@@ -715,6 +774,44 @@ async function saveUiSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWinAdminSettings() {
|
||||
const data = await fetchWinAdminSettings();
|
||||
winAdminLoaded.value = data;
|
||||
winAdminForm.user = data.user ?? "";
|
||||
winAdminForm.password = "";
|
||||
}
|
||||
|
||||
async function saveWinAdminSettings() {
|
||||
savingWinAdmin.value = true;
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
try {
|
||||
const payload: { user?: string; password?: string } = {};
|
||||
if (winAdminForm.user.trim()) {
|
||||
payload.user = winAdminForm.user.trim();
|
||||
}
|
||||
if (winAdminForm.password.trim()) {
|
||||
payload.password = winAdminForm.password;
|
||||
}
|
||||
if (!payload.user && !payload.password) {
|
||||
error.value = "Укажите логин и/или новый пароль Windows admin";
|
||||
return;
|
||||
}
|
||||
if (!winAdminLoaded.value?.configured && (!payload.user || !payload.password)) {
|
||||
error.value = "Для первой настройки укажите логин и пароль";
|
||||
return;
|
||||
}
|
||||
winAdminLoaded.value = await updateWinAdminSettings(payload);
|
||||
winAdminForm.user = winAdminLoaded.value.user ?? winAdminForm.user;
|
||||
winAdminForm.password = "";
|
||||
success.value = "Учётные данные Windows admin сохранены.";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "Ошибка сохранения Windows admin";
|
||||
} finally {
|
||||
savingWinAdmin.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMobileSection() {
|
||||
mobileLoaded.value = await fetchMobileSettings();
|
||||
mobileForm.devices_allowed = mobileLoaded.value.devices_allowed;
|
||||
@@ -860,6 +957,7 @@ async function load() {
|
||||
emailForm.smtp_starttls = data.email.smtp_starttls;
|
||||
emailForm.smtp_ssl = data.email.smtp_ssl;
|
||||
await loadUiSettings();
|
||||
await loadWinAdminSettings();
|
||||
await loadSeverityOverrides();
|
||||
await loadMobileSection();
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user