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:
2026-06-20 00:01:17 +10:00
parent df4c93d243
commit 154ba3efc2
16 changed files with 602 additions and 14 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sac-ui",
"private": true,
"version": "0.10.0",
"version": "0.10.1",
"type": "module",
"scripts": {
"dev": "vite",
+34
View File
@@ -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 -1
View File
@@ -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}`;
+38
View File
@@ -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;
+98
View File
@@ -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) {