fix: SAC pushes RDP bundle from git via WinRM, no client git (0.20.14)
This commit is contained in:
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.models import Host
|
from app.models import Host
|
||||||
|
from app.services.agent_git_release import _cache_dir, clone_or_update_repo
|
||||||
|
|
||||||
_CLIXML_PROGRESS_NOISE = frozenset(
|
_CLIXML_PROGRESS_NOISE = frozenset(
|
||||||
{
|
{
|
||||||
@@ -16,6 +20,26 @@ _CLIXML_PROGRESS_NOISE = frozenset(
|
|||||||
)
|
)
|
||||||
_CLIXML_MARKER = "#< CLIXML"
|
_CLIXML_MARKER = "#< CLIXML"
|
||||||
|
|
||||||
|
RDP_REMOTE_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging"
|
||||||
|
RDP_BUNDLE_FILES = (
|
||||||
|
"Login_Monitor.ps1",
|
||||||
|
"Sac-Client.ps1",
|
||||||
|
"RdpMonitor-TaskQuery.ps1",
|
||||||
|
"version.txt",
|
||||||
|
"Deploy-LoginMonitor.ps1",
|
||||||
|
"Restart-RdpLoginMonitor.ps1",
|
||||||
|
"login_monitor.settings.example.ps1",
|
||||||
|
)
|
||||||
|
RDP_BUNDLE_REQUIRED = frozenset(
|
||||||
|
{
|
||||||
|
"Login_Monitor.ps1",
|
||||||
|
"Sac-Client.ps1",
|
||||||
|
"version.txt",
|
||||||
|
"Deploy-LoginMonitor.ps1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_WINRM_FILE_CHUNK_BYTES = 20_000
|
||||||
|
|
||||||
|
|
||||||
class WinAdminNotConfiguredError(Exception):
|
class WinAdminNotConfiguredError(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -289,55 +313,103 @@ def _wrap_powershell_body(body: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _rdp_update_powershell_script(
|
def _custom_deploy_body(script_path: str) -> str:
|
||||||
script_path: str,
|
literal = _powershell_literal_path(script_path.strip())
|
||||||
*,
|
return (
|
||||||
repo_url: str = "",
|
f"$p = '{literal}'\n"
|
||||||
git_branch: str = "main",
|
"if (-not (Test-Path -LiteralPath $p)) { throw \"Deploy script not found: $p\" }\n"
|
||||||
) -> str:
|
"Write-Output \"Running: $p\"\n"
|
||||||
script = script_path.strip()
|
"& $p"
|
||||||
if script:
|
)
|
||||||
literal = _powershell_literal_path(script)
|
|
||||||
body = (
|
|
||||||
f"$p = '{literal}'\n"
|
|
||||||
"if (-not (Test-Path -LiteralPath $p)) { throw \"Deploy script not found: $p\" }\n"
|
|
||||||
"Write-Output \"Running: $p\"\n"
|
|
||||||
"& $p"
|
|
||||||
)
|
|
||||||
return _wrap_powershell_body(body)
|
|
||||||
|
|
||||||
safe_repo = _powershell_literal_path(
|
|
||||||
(repo_url or "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git").strip()
|
def _prepare_staging_body(staging_path: str) -> str:
|
||||||
|
literal = _powershell_literal_path(staging_path)
|
||||||
|
return (
|
||||||
|
f"$staging = '{literal}'\n"
|
||||||
|
"if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }\n"
|
||||||
|
"New-Item -ItemType Directory -Path $staging -Force | Out-Null\n"
|
||||||
|
"Write-Output \"Staging ready: $staging\"\n"
|
||||||
)
|
)
|
||||||
safe_branch = _powershell_literal_path((git_branch or "main").strip() or "main")
|
|
||||||
body = (
|
|
||||||
f"$gitUrl = '{safe_repo}'\n"
|
def _deploy_from_staging_body(staging_path: str) -> str:
|
||||||
f"$branch = '{safe_branch}'\n"
|
literal = _powershell_literal_path(staging_path)
|
||||||
"$repoDir = Join-Path $env:ProgramData 'RDP-login-monitor\\_sac_git'\n"
|
return (
|
||||||
"$gitCmd = Get-Command git.exe -ErrorAction SilentlyContinue\n"
|
f"$staging = '{literal}'\n"
|
||||||
"if (-not $gitCmd) {\n"
|
"$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n"
|
||||||
" throw 'git.exe not found on client PC — install Git for Windows or set win_agent_update_script in SAC'\n"
|
"if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n"
|
||||||
"}\n"
|
"Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n"
|
||||||
"Write-Output \"git: $gitUrl (branch $branch) -> $repoDir\"\n"
|
"& $deploy -SourceShareRoot $staging\n"
|
||||||
"if (-not (Test-Path -LiteralPath (Join-Path $repoDir '.git'))) {\n"
|
)
|
||||||
" if (Test-Path -LiteralPath $repoDir) { Remove-Item -LiteralPath $repoDir -Recurse -Force }\n"
|
|
||||||
" New-Item -ItemType Directory -Path (Split-Path $repoDir -Parent) -Force | Out-Null\n"
|
|
||||||
" & git.exe clone -b $branch $gitUrl $repoDir\n"
|
def _write_file_chunk_body(remote_path: str, b64_chunk: str, *, append: bool) -> str:
|
||||||
" if ($LASTEXITCODE -ne 0) { throw \"git clone failed (exit $LASTEXITCODE)\" }\n"
|
literal = _powershell_literal_path(remote_path)
|
||||||
"} else {\n"
|
if append:
|
||||||
" Push-Location $repoDir\n"
|
return (
|
||||||
" & git.exe fetch origin\n"
|
f"$path = '{literal}'\n"
|
||||||
" & git.exe reset --hard \"origin/$branch\"\n"
|
f"$bytes = [Convert]::FromBase64String('{b64_chunk}')\n"
|
||||||
" $code = $LASTEXITCODE\n"
|
"$stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Append)\n"
|
||||||
" Pop-Location\n"
|
"try { $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Close() }\n"
|
||||||
" if ($code -ne 0) { throw \"git reset failed (exit $code)\" }\n"
|
)
|
||||||
"}\n"
|
return (
|
||||||
"$deploy = Join-Path $repoDir 'Deploy-LoginMonitor.ps1'\n"
|
f"$path = '{literal}'\n"
|
||||||
"if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in git checkout: $repoDir\" }\n"
|
f"$bytes = [Convert]::FromBase64String('{b64_chunk}')\n"
|
||||||
"Write-Output \"Running: $deploy -SourceShareRoot $repoDir\"\n"
|
"$dir = Split-Path -LiteralPath $path -Parent\n"
|
||||||
"& $deploy -SourceShareRoot $repoDir\n"
|
"if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }\n"
|
||||||
|
"[System.IO.File]::WriteAllBytes($path, $bytes)\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _winrm_push_file(
|
||||||
|
*,
|
||||||
|
target: str,
|
||||||
|
user: str,
|
||||||
|
password: str,
|
||||||
|
remote_path: str,
|
||||||
|
data: bytes,
|
||||||
|
) -> WinRmCmdResult:
|
||||||
|
last: WinRmCmdResult | None = None
|
||||||
|
if not data:
|
||||||
|
body = (
|
||||||
|
f"$path = '{_powershell_literal_path(remote_path)}'\n"
|
||||||
|
"$dir = Split-Path -LiteralPath $path -Parent\n"
|
||||||
|
"if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }\n"
|
||||||
|
"[System.IO.File]::WriteAllBytes($path, [byte[]]@())\n"
|
||||||
|
)
|
||||||
|
return run_winrm_ps(
|
||||||
|
target=target,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
script=_wrap_powershell_body(body),
|
||||||
|
timeout_sec=120,
|
||||||
|
)
|
||||||
|
for offset in range(0, len(data), _WINRM_FILE_CHUNK_BYTES):
|
||||||
|
chunk = data[offset : offset + _WINRM_FILE_CHUNK_BYTES]
|
||||||
|
b64_chunk = base64.b64encode(chunk).decode("ascii")
|
||||||
|
body = _write_file_chunk_body(remote_path, b64_chunk, append=offset > 0)
|
||||||
|
last = run_winrm_ps(
|
||||||
|
target=target,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
script=_wrap_powershell_body(body),
|
||||||
|
timeout_sec=120,
|
||||||
|
)
|
||||||
|
if not last.ok:
|
||||||
|
return last
|
||||||
|
assert last is not None
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_rdp_bundle_dir(repo_url: str, git_branch: str) -> Path:
|
||||||
|
settings = get_settings()
|
||||||
|
return clone_or_update_repo(
|
||||||
|
repo_url,
|
||||||
|
git_branch,
|
||||||
|
_cache_dir(),
|
||||||
|
token=(settings.sac_agent_git_token or "").strip(),
|
||||||
)
|
)
|
||||||
return _wrap_powershell_body(body)
|
|
||||||
|
|
||||||
|
|
||||||
def run_winrm_rdp_monitor_update(
|
def run_winrm_rdp_monitor_update(
|
||||||
@@ -349,17 +421,102 @@ def run_winrm_rdp_monitor_update(
|
|||||||
repo_url: str = "",
|
repo_url: str = "",
|
||||||
git_branch: str = "main",
|
git_branch: str = "main",
|
||||||
) -> WinRmCmdResult:
|
) -> WinRmCmdResult:
|
||||||
return run_winrm_ps(
|
target = target.strip()
|
||||||
|
if script_path.strip():
|
||||||
|
return run_winrm_ps(
|
||||||
|
target=target,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
script=_wrap_powershell_body(_custom_deploy_body(script_path)),
|
||||||
|
timeout_sec=900,
|
||||||
|
)
|
||||||
|
|
||||||
|
effective_repo = (
|
||||||
|
repo_url or "https://git.kalinamall.ru/PapaTramp/RDP-login-monitor.git"
|
||||||
|
).strip()
|
||||||
|
effective_branch = (git_branch or "main").strip() or "main"
|
||||||
|
if not effective_repo:
|
||||||
|
return WinRmCmdResult(
|
||||||
|
ok=False,
|
||||||
|
message="rdp_git_repo_url is not configured in SAC",
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo_dir = _fetch_rdp_bundle_dir(effective_repo, effective_branch)
|
||||||
|
except Exception as exc:
|
||||||
|
return WinRmCmdResult(
|
||||||
|
ok=False,
|
||||||
|
message=f"SAC failed to fetch RDP bundle from git: {exc}",
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = [name for name in RDP_BUNDLE_REQUIRED if not (repo_dir / name).is_file()]
|
||||||
|
if missing:
|
||||||
|
return WinRmCmdResult(
|
||||||
|
ok=False,
|
||||||
|
message=f"RDP bundle incomplete on SAC: missing {', '.join(missing)}",
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
|
||||||
|
staging = RDP_REMOTE_STAGING
|
||||||
|
prep = run_winrm_ps(
|
||||||
target=target,
|
target=target,
|
||||||
user=user,
|
user=user,
|
||||||
password=password,
|
password=password,
|
||||||
script=_rdp_update_powershell_script(
|
script=_wrap_powershell_body(_prepare_staging_body(staging)),
|
||||||
script_path,
|
timeout_sec=120,
|
||||||
repo_url=repo_url,
|
)
|
||||||
git_branch=git_branch,
|
if not prep.ok:
|
||||||
),
|
return prep
|
||||||
|
|
||||||
|
pushed: list[str] = []
|
||||||
|
log_parts = [prep.stdout]
|
||||||
|
for filename in RDP_BUNDLE_FILES:
|
||||||
|
local_file = repo_dir / filename
|
||||||
|
if not local_file.is_file():
|
||||||
|
continue
|
||||||
|
remote_file = f"{staging}\\{filename}"
|
||||||
|
push_result = _winrm_push_file(
|
||||||
|
target=target,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
remote_path=remote_file,
|
||||||
|
data=local_file.read_bytes(),
|
||||||
|
)
|
||||||
|
log_parts.append(push_result.stdout)
|
||||||
|
if not push_result.ok:
|
||||||
|
return WinRmCmdResult(
|
||||||
|
ok=False,
|
||||||
|
message=f"Failed to push {filename} to client: {push_result.message}",
|
||||||
|
target=target,
|
||||||
|
stdout="\n".join(part.strip() for part in log_parts if part.strip()),
|
||||||
|
stderr=push_result.stderr,
|
||||||
|
exit_code=push_result.exit_code,
|
||||||
|
)
|
||||||
|
pushed.append(filename)
|
||||||
|
|
||||||
|
deploy = run_winrm_ps(
|
||||||
|
target=target,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
script=_wrap_powershell_body(_deploy_from_staging_body(staging)),
|
||||||
timeout_sec=900,
|
timeout_sec=900,
|
||||||
)
|
)
|
||||||
|
header = f"SAC pushed {len(pushed)} file(s) from git to {staging}"
|
||||||
|
stdout = "\n\n".join(
|
||||||
|
part.strip()
|
||||||
|
for part in [header, "\n".join(part.strip() for part in log_parts if part.strip()), deploy.stdout]
|
||||||
|
if part.strip()
|
||||||
|
)
|
||||||
|
return WinRmCmdResult(
|
||||||
|
ok=deploy.ok,
|
||||||
|
message=deploy.message,
|
||||||
|
target=target,
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=deploy.stderr,
|
||||||
|
exit_code=deploy.exit_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_winrm_on_host_targets(
|
def run_winrm_on_host_targets(
|
||||||
|
|||||||
@@ -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.20.13"
|
APP_VERSION = "0.20.14"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -1,42 +1,92 @@
|
|||||||
"""WinRM command builder and CLIXML error parsing tests."""
|
"""WinRM command builder, bundle push and CLIXML error parsing tests."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from app.services.winrm_connect import (
|
from app.services.winrm_connect import (
|
||||||
|
RDP_BUNDLE_REQUIRED,
|
||||||
|
RDP_REMOTE_STAGING,
|
||||||
|
WinRmCmdResult,
|
||||||
_clixml_to_plain,
|
_clixml_to_plain,
|
||||||
_rdp_update_powershell_script,
|
_custom_deploy_body,
|
||||||
|
_deploy_from_staging_body,
|
||||||
|
_prepare_staging_body,
|
||||||
_winrm_failure_detail,
|
_winrm_failure_detail,
|
||||||
|
_write_file_chunk_body,
|
||||||
|
run_winrm_rdp_monitor_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_rdp_update_default_script_clones_git_and_runs_local_deploy():
|
def test_prepare_staging_recreates_remote_dir():
|
||||||
script = _rdp_update_powershell_script("")
|
script = _prepare_staging_body(RDP_REMOTE_STAGING)
|
||||||
assert "$ProgressPreference = 'SilentlyContinue'" in script
|
assert "Remove-Item" in script
|
||||||
assert "NETLOGON" not in script
|
assert "_sac_staging" in script
|
||||||
assert "_sac_git" in script
|
|
||||||
assert "git.kalinamall.ru/PapaTramp/RDP-login-monitor.git" in script
|
|
||||||
assert "-SourceShareRoot $repoDir" in script
|
|
||||||
|
|
||||||
|
|
||||||
def test_rdp_update_default_script_honors_sac_git_settings():
|
def test_deploy_from_staging_runs_local_bundle():
|
||||||
script = _rdp_update_powershell_script(
|
script = _deploy_from_staging_body(RDP_REMOTE_STAGING)
|
||||||
"",
|
assert "-SourceShareRoot" in script
|
||||||
repo_url="https://git.example.com/org/rdp.git",
|
assert "Deploy-LoginMonitor.ps1" in script
|
||||||
git_branch="release",
|
|
||||||
)
|
|
||||||
assert "git.example.com/org/rdp.git" in script
|
|
||||||
assert "$branch = 'release'" in script
|
|
||||||
|
|
||||||
|
|
||||||
def test_rdp_update_custom_script_uses_literal_path():
|
def test_write_file_chunk_body_supports_append():
|
||||||
script = _rdp_update_powershell_script(r"\\b26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1")
|
first = _write_file_chunk_body(r"C:\temp\a.ps1", "YQ==", append=False)
|
||||||
assert r"\\b26\NETLOGON\RDP-login-monitor\Deploy-LoginMonitor.ps1" in script
|
second = _write_file_chunk_body(r"C:\temp\a.ps1", "Yg==", append=True)
|
||||||
|
assert "WriteAllBytes" in first
|
||||||
|
assert "Append" in second
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_deploy_script_uses_literal_path():
|
||||||
|
script = _custom_deploy_body(r"C:\tmp\Deploy-LoginMonitor.ps1")
|
||||||
|
assert r"C:\tmp\Deploy-LoginMonitor.ps1" in script
|
||||||
assert "Test-Path -LiteralPath" in script
|
assert "Test-Path -LiteralPath" in script
|
||||||
|
|
||||||
|
|
||||||
def test_rdp_update_custom_script_escapes_single_quotes():
|
def test_custom_deploy_script_escapes_single_quotes():
|
||||||
script = _rdp_update_powershell_script(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1")
|
script = _custom_deploy_body(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1")
|
||||||
assert "O''Brien" in script
|
assert "O''Brien" in script
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_winrm_rdp_monitor_update_pushes_bundle_from_sac_git(tmp_path: Path):
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
for name in RDP_BUNDLE_REQUIRED:
|
||||||
|
(repo / name).write_text(f"content-{name}", encoding="utf-8")
|
||||||
|
(repo / "Sac-Client.ps1").write_text("Sac client", encoding="utf-8")
|
||||||
|
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def fake_run_ps(*, script: str, **kwargs) -> WinRmCmdResult:
|
||||||
|
calls.append(script)
|
||||||
|
if "Remove-Item" in script:
|
||||||
|
return WinRmCmdResult(ok=True, message="staging ok", target="pc", stdout="Staging ready")
|
||||||
|
if "Deploy-LoginMonitor.ps1 missing" in script or "& $deploy" in script:
|
||||||
|
return WinRmCmdResult(
|
||||||
|
ok=True,
|
||||||
|
message="WinRM OK (pc), exit 0",
|
||||||
|
target="pc",
|
||||||
|
stdout="deployed 1.2.3",
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
return WinRmCmdResult(ok=True, message="chunk ok", target="pc", exit_code=0)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.winrm_connect._fetch_rdp_bundle_dir", return_value=repo),
|
||||||
|
patch("app.services.winrm_connect.run_winrm_ps", side_effect=fake_run_ps),
|
||||||
|
):
|
||||||
|
result = run_winrm_rdp_monitor_update(
|
||||||
|
target="pc",
|
||||||
|
user="B26\\admin",
|
||||||
|
password="pw",
|
||||||
|
repo_url="https://git.example.com/RDP-login-monitor.git",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.ok is True
|
||||||
|
assert "SAC pushed" in result.stdout
|
||||||
|
assert any("WriteAllBytes" in call or "Append" in call for call in calls)
|
||||||
|
assert "git.exe" not in "\n".join(calls)
|
||||||
|
|
||||||
|
|
||||||
def test_winrm_failure_detail_extracts_message_from_clixml():
|
def test_winrm_failure_detail_extracts_message_from_clixml():
|
||||||
clixml = (
|
clixml = (
|
||||||
"#< CLIXML\n"
|
"#< CLIXML\n"
|
||||||
|
|||||||
@@ -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.20.13";
|
export const APP_VERSION = "0.20.14";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user