feat: manual host add from Hosts list via WinRM/SSH (0.4.5)

Add host button probes Windows by hostname or Linux by IP, registers host in SAC and deploys agent in background.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-25 15:34:12 +10:00
parent 5a7909bb55
commit fd1be5e7e4
9 changed files with 550 additions and 4 deletions
+49 -2
View File
@@ -1,5 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status from typing import Literal
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -176,6 +178,11 @@ def list_hosts(
) )
class HostManualAddBody(BaseModel):
platform: Literal["windows", "linux"]
target: str = Field(..., min_length=1, max_length=253)
def _host_detail_from_model( def _host_detail_from_model(
host: Host, host: Host,
*, *,
@@ -298,6 +305,46 @@ class HostRemoteActionStartResponse(BaseModel):
message: str message: str
@router.post(
"/manual-add",
response_model=HostRemoteActionStartResponse,
status_code=status.HTTP_202_ACCEPTED,
)
def post_host_manual_add(
body: HostManualAddBody,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> HostRemoteActionStartResponse:
from app.services.host_manual_add import ManualHostAddError, prepare_manual_host_add
try:
host = prepare_manual_host_add(db, platform=body.platform, target=body.target)
except ManualHostAddError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if body.platform == "windows":
title = f"Ручное добавление Windows: {host.hostname}"
else:
title = f"Ручное добавление Linux: {host.hostname}"
try:
start_host_remote_action(
db,
host,
title=title,
runner=run_agent_fallback_action,
)
except RemoteActionAlreadyRunningError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return HostRemoteActionStartResponse(
status="running",
host_id=host.id,
title=title,
message="Проверка пройдена, установка агента запущена на сервере SAC",
)
class HostRemoteActionJobResponse(BaseModel): class HostRemoteActionJobResponse(BaseModel):
active: bool active: bool
host_id: int host_id: int
+127
View File
@@ -0,0 +1,127 @@
"""Probe remote host and register in SAC before agent deploy (WinRM / SSH)."""
from __future__ import annotations
import re
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Host
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
from app.services.linux_admin_settings import get_effective_linux_admin_config
from app.services.ssh_connect import _is_ipv4, _ssh_output_hostname, test_ssh_connection
from app.services.win_admin_settings import get_effective_win_admin_config
from app.services.winrm_connect import test_winrm_connection
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
class ManualHostAddError(Exception):
pass
def validate_windows_target(target: str) -> str:
text = (target or "").strip()
if not text:
raise ManualHostAddError("Укажите имя Windows-ПК")
if _IPV4_RE.match(text):
raise ManualHostAddError(
"Для Windows укажите имя ПК (NetBIOS/DNS), не IP — WinRM с Kerberos по IP ненадёжен"
)
short = text.split(".")[0].split("\\")[-1].strip()
if not short or " " in short:
raise ManualHostAddError("Некорректное имя хоста")
return short
def validate_linux_target(target: str) -> str:
text = (target or "").strip()
if not text:
raise ManualHostAddError("Укажите IP или hostname Linux-хоста")
return text
def probe_windows_host(db: Session, hostname: str) -> str:
cfg = get_effective_win_admin_config(db)
if not cfg.configured:
raise ManualHostAddError("Windows domain admin не настроен в SAC")
probe = test_winrm_connection(
target=hostname,
user=cfg.user,
password=cfg.password,
)
if not probe.ok:
raise ManualHostAddError(probe.message)
return (probe.hostname or hostname).strip()
def probe_linux_host(db: Session, target: str) -> tuple[str, str | None]:
cfg = get_effective_linux_admin_config(db)
if not cfg.configured:
raise ManualHostAddError("Linux SSH admin не настроен в SAC")
probe = test_ssh_connection(
target=target,
user=cfg.user,
password=cfg.password,
)
if not probe.ok:
raise ManualHostAddError(probe.message)
hostname = _ssh_output_hostname(probe.stdout) or target
ipv4 = target if _is_ipv4(target) else None
return hostname, ipv4
def get_or_create_manual_host(
db: Session,
*,
hostname: str,
product: str,
os_family: str,
ipv4: str | None = None,
) -> Host:
host = db.scalar(
select(Host).where(Host.hostname == hostname, Host.product == product).limit(1)
)
now = datetime.now(timezone.utc)
if host is None:
host = Host(
hostname=hostname,
os_family=os_family,
product=product,
ipv4=ipv4,
last_seen_at=now,
)
db.add(host)
else:
host.os_family = os_family
if ipv4:
host.ipv4 = ipv4
host.last_seen_at = now
db.flush()
return host
def prepare_manual_host_add(db: Session, *, platform: str, target: str) -> Host:
platform_norm = (platform or "").strip().lower()
if platform_norm == "windows":
win_target = validate_windows_target(target)
hostname = probe_windows_host(db, win_target)
return get_or_create_manual_host(
db,
hostname=hostname,
product=PRODUCT_RDP,
os_family="windows",
)
if platform_norm == "linux":
linux_target = validate_linux_target(target)
hostname, ipv4 = probe_linux_host(db, linux_target)
return get_or_create_manual_host(
db,
hostname=hostname,
product=PRODUCT_SSH,
os_family="linux",
ipv4=ipv4,
)
raise ManualHostAddError("platform должен быть windows или linux")
+1 -1
View File
@@ -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.4.4" APP_VERSION = "0.4.5"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+141
View File
@@ -0,0 +1,141 @@
"""Manual host add: validation, probe, deploy kick-off."""
from unittest.mock import patch
import pytest
from app.models import Host
from app.services.host_manual_add import (
ManualHostAddError,
prepare_manual_host_add,
validate_linux_target,
validate_windows_target,
)
from app.services.winrm_connect import WinRmTestResult
from tests.test_agent_update import wait_remote_job
def test_validate_windows_rejects_ip():
with pytest.raises(ManualHostAddError, match="не IP"):
validate_windows_target("10.10.36.9")
def test_validate_windows_accepts_hostname():
assert validate_windows_target("WORKSTATION-01") == "WORKSTATION-01"
assert validate_windows_target(r"B26\WORKSTATION-01") == "WORKSTATION-01"
def test_validate_linux_accepts_ip():
assert validate_linux_target("10.10.36.9") == "10.10.36.9"
def test_prepare_manual_host_windows(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()
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
mock_win.return_value = WinRmTestResult(
ok=True,
message="WinRM OK, hostname=WORKSTATION-01",
target="WORKSTATION-01",
hostname="WORKSTATION-01",
)
host = prepare_manual_host_add(
db_session,
platform="windows",
target="WORKSTATION-01",
)
assert host.hostname == "WORKSTATION-01"
assert host.product == "rdp-login-monitor"
assert host.os_family == "windows"
def test_api_manual_add_windows(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()
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
mock_win.return_value = WinRmTestResult(
ok=True,
message="WinRM OK, hostname=NEW-PC",
target="NEW-PC",
hostname="NEW-PC",
)
with patch("app.services.agent_update.run_winrm_rdp_monitor_update") as mock_deploy:
from app.services.winrm_connect import WinRmCmdResult
mock_deploy.return_value = WinRmCmdResult(
ok=True,
message="deploy ok",
target="NEW-PC",
stdout="2.1.8-SAC",
)
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "windows", "target": "NEW-PC"},
headers=jwt_headers,
)
assert response.status_code == 202
body = response.json()
assert body["status"] == "running"
host_id = body["host_id"]
host = db_session.get(Host, host_id)
assert host is not None
assert host.hostname == "NEW-PC"
job = wait_remote_job(client, host_id, jwt_headers)
assert job["ok"] is True
def test_api_manual_add_rejects_windows_ip(jwt_headers, client):
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "windows", "target": "192.168.1.10"},
headers=jwt_headers,
)
assert response.status_code == 400
assert "не IP" in response.json()["detail"]
def test_api_manual_add_linux(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_ssh_connection") as mock_ssh:
from app.services.ssh_connect import SshCommandResult
mock_ssh.return_value = SshCommandResult(
ok=True,
message="SSH OK, hostname=linux-srv",
target="10.10.36.9",
stdout="linux-srv\n",
)
with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update:
mock_update.return_value = SshCommandResult(
ok=True,
message="updated",
target="10.10.36.9",
agent_version="2.1.5-SAC",
)
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "linux", "target": "10.10.36.9"},
headers=jwt_headers,
)
assert response.status_code == 202
host_id = response.json()["host_id"]
host = db_session.get(Host, host_id)
assert host.hostname == "linux-srv"
assert host.ipv4 == "10.10.36.9"
job = wait_remote_job(client, host_id, jwt_headers, timeout=8.0)
assert job["ok"] is True
+12
View File
@@ -575,6 +575,18 @@ export function runHostAgentUpdateFallback(hostId: number): Promise<HostRemoteAc
); );
} }
export interface HostManualAddRequest {
platform: "windows" | "linux";
target: string;
}
export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteActionStartResult> {
return apiFetch<HostRemoteActionStartResult>("/api/v1/hosts/manual-add", {
method: "POST",
body: JSON.stringify(body),
});
}
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> { export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`); return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
} }
@@ -0,0 +1,157 @@
<template>
<div v-if="open" class="modal-backdrop" @click.self="emit('close')">
<div class="modal-card host-manual-add-modal" role="dialog" aria-modal="true" aria-label="Добавить хост вручную">
<h3>Добавить хост вручную</h3>
<p class="muted">
SAC проверит подключение и установит агент (RDP-login-monitor или ssh-monitor) через WinRM / SSH.
</p>
<fieldset class="platform-fieldset">
<legend>Тип хоста</legend>
<label class="platform-option">
<input v-model="platform" type="radio" value="windows" :disabled="submitting" />
Windows только по имени ПК (WinRM)
</label>
<label class="platform-option">
<input v-model="platform" type="radio" value="linux" :disabled="submitting" />
Linux IP или hostname (SSH)
</label>
</fieldset>
<label class="target-label">
{{ platform === "windows" ? "Имя ПК (NetBIOS / DNS)" : "IP или hostname" }}
<input
v-model="target"
type="text"
:placeholder="platform === 'windows' ? 'WORKSTATION-01' : '10.10.36.9'"
:disabled="submitting"
@keyup.enter="submit"
/>
</label>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="submitting" class="muted">Проверка WinRM/SSH и запуск установки</p>
<div class="modal-actions">
<button type="button" class="secondary" :disabled="submitting" @click="emit('close')">Отмена</button>
<button type="button" :disabled="submitting || !target.trim()" @click="submit">
{{ submitting ? "Выполняется" : "Добавить и установить" }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { addHostManually, ApiError } from "../api";
const props = defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
close: [];
started: [payload: { hostId: number; title: string }];
}>();
const platform = ref<"windows" | "linux">("windows");
const target = ref("");
const submitting = ref(false);
const error = ref("");
watch(
() => props.open,
(isOpen) => {
if (!isOpen) return;
platform.value = "windows";
target.value = "";
submitting.value = false;
error.value = "";
},
);
async function submit() {
const trimmed = target.value.trim();
if (!trimmed || submitting.value) return;
submitting.value = true;
error.value = "";
try {
const result = await addHostManually({
platform: platform.value,
target: trimmed,
});
emit("started", { hostId: result.host_id, title: result.title });
emit("close");
} catch (e) {
error.value =
e instanceof ApiError ? e.message : e instanceof Error ? e.message : "Не удалось добавить хост";
} finally {
submitting.value = false;
}
}
</script>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.55);
}
.modal-card {
width: min(100%, 28rem);
padding: 1rem 1.1rem 1.1rem;
background: var(--card-bg, #1e2430);
border: 1px solid var(--border, #3a4556);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.modal-card h3 {
margin: 0 0 0.5rem;
}
.platform-fieldset {
margin: 0.75rem 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border, #3a4556);
border-radius: 6px;
}
.platform-fieldset legend {
padding: 0 0.25rem;
font-size: 0.85rem;
color: #9aa4b2;
}
.platform-option {
display: block;
margin: 0.35rem 0;
font-size: 0.92rem;
}
.target-label {
display: block;
margin-top: 0.75rem;
}
.target-label input {
display: block;
width: 100%;
margin-top: 0.35rem;
box-sizing: border-box;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
</style>
@@ -163,6 +163,54 @@ async function executeRemoteAction(
} }
} }
export async function pollHostRemoteAction(
hostId: number,
title: string,
): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) {
const existing = findLoadingLogForHost(hostId);
if (existing) {
existing.open = true;
}
return existingPromise;
}
const entry: HostRemoteActionLogEntry = {
id: newLogId(),
hostId,
open: true,
loading: true,
ok: null,
title,
message: "Выполняется на удалённом хосте…",
output: "",
};
hostRemoteActionLogs.push(entry);
const promise = (async () => {
try {
const job = await pollRemoteJob(hostId, entry);
finishEntry(entry, job);
await patchHostFromJob(hostId, job);
return job;
} catch (e) {
entry.loading = false;
entry.ok = false;
entry.message = e instanceof Error ? e.message : "Ошибка выполнения";
entry.open = true;
return null;
}
})();
hostRunningPromises.set(hostId, promise);
try {
return await promise;
} finally {
hostRunningPromises.delete(hostId);
}
}
export async function runHostRemoteAction( export async function runHostRemoteAction(
hostId: number, hostId: number,
title: string, title: string,
+1 -1
View File
@@ -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.4.4"; export const APP_VERSION = "0.4.5";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+14
View File
@@ -15,6 +15,7 @@
</select> </select>
</label> </label>
<button type="button" class="secondary" @click="applyFilters">Найти</button> <button type="button" class="secondary" @click="applyFilters">Найти</button>
<button type="button" @click="manualAddOpen = true">Добавить вручную</button>
</div> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p> <p v-else-if="loading">Загрузка</p>
@@ -108,6 +109,11 @@
</tbody> </tbody>
</table> </table>
</template> </template>
<HostManualAddModal
:open="manualAddOpen"
@close="manualAddOpen = false"
@started="onManualAddStarted"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -133,10 +139,12 @@ import {
} from "../utils/hostAgentUpgrade"; } from "../utils/hostAgentUpgrade";
import { import {
isHostRemoteActionActive, isHostRemoteActionActive,
pollHostRemoteAction,
runHostRemoteAction, runHostRemoteAction,
showHostRemoteActionLog, showHostRemoteActionLog,
} from "../composables/useHostRemoteAction"; } from "../composables/useHostRemoteAction";
import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue"; import HostAgentVersionUpgrade from "../components/HostAgentVersionUpgrade.vue";
import HostManualAddModal from "../components/HostManualAddModal.vue";
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -200,6 +208,7 @@ const initialSort = loadHostsSortPrefs();
const sortBy = ref<SortKey>(initialSort.sortBy); const sortBy = ref<SortKey>(initialSort.sortBy);
const sortDir = ref<"asc" | "desc">(initialSort.sortDir); const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
const deletingId = ref<number | null>(null); const deletingId = ref<number | null>(null);
const manualAddOpen = ref(false);
let patchingHostRow = false; let patchingHostRow = false;
let unsubscribeHostPatch: (() => void) | null = null; let unsubscribeHostPatch: (() => void) | null = null;
@@ -404,6 +413,11 @@ interface HostDeleteResponse {
deleted_problems: number; deleted_problems: number;
} }
async function onManualAddStarted(payload: { hostId: number; title: string }) {
await loadHosts();
void pollHostRemoteAction(payload.hostId, payload.title);
}
async function confirmDelete(h: HostSummary) { async function confirmDelete(h: HostSummary) {
const label = h.display_name || h.hostname; const label = h.display_name || h.hostname;
const n = h.event_count ?? 0; const n = h.event_count ?? 0;