feat: Linux SSH admin and remote ssh-monitor update (0.11.0)

This commit is contained in:
PTah
2026-06-20 00:25:35 +10:00
parent 279ea925a8
commit cc8f50de89
17 changed files with 805 additions and 5 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sac-ui",
"private": true,
"version": "0.10.4",
"version": "0.11.0",
"type": "module",
"scripts": {
"dev": "vite",
+42
View File
@@ -446,6 +446,48 @@ export function testHostWinRm(hostId: number): Promise<HostWinRmTestResult> {
});
}
export interface LinuxAdminSettings {
configured: boolean;
user: string | null;
password_hint: string | null;
source: string;
}
export function fetchLinuxAdminSettings(): Promise<LinuxAdminSettings> {
return apiFetch<LinuxAdminSettings>("/api/v1/settings/linux-admin");
}
export function updateLinuxAdminSettings(payload: {
user?: string | null;
password?: string | null;
}): Promise<LinuxAdminSettings> {
return apiFetch<LinuxAdminSettings>("/api/v1/settings/linux-admin", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export interface HostSshActionResult {
ok: boolean;
message: string;
target: string;
stdout: string | null;
stderr: string | null;
exit_code: number | null;
}
export function testHostSsh(hostId: number): Promise<HostSshActionResult> {
return apiFetch<HostSshActionResult>(`/api/v1/hosts/${hostId}/actions/ssh-test`, {
method: "POST",
});
}
export function updateHostAgentViaSsh(hostId: number): Promise<HostSshActionResult> {
return apiFetch<HostSshActionResult>(`/api/v1/hosts/${hostId}/actions/agent-update`, {
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.4";
export const APP_VERSION = "0.11.0";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+106
View File
@@ -58,6 +58,26 @@
</button>
<p v-if="winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p>
</div>
<div v-if="isLinuxHost" class="host-actions">
<button
type="button"
class="secondary"
:disabled="testingSsh"
@click="runSshTest"
>
{{ testingSsh ? "Проверка…" : "Проверить SSH" }}
</button>
<button
type="button"
class="secondary"
:disabled="updatingAgent"
@click="runAgentUpdate"
>
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor" }}
</button>
<p v-if="sshActionMessage" :class="sshActionOk ? 'success' : 'error'">{{ sshActionMessage }}</p>
<pre v-if="sshActionOutput" class="host-ssh-output">{{ sshActionOutput }}</pre>
</div>
</div>
<section v-if="inventory" class="host-inventory card">
@@ -177,6 +197,8 @@ import {
fetchHost,
fetchRecentEvents,
testHostWinRm,
testHostSsh,
updateHostAgentViaSsh,
type EventListResponse,
type HostDetail,
} from "../api";
@@ -195,6 +217,11 @@ const eventsPageSize = 50;
const testingWinRm = ref(false);
const winRmTestMessage = ref("");
const winRmTestOk = ref(false);
const testingSsh = ref(false);
const updatingAgent = ref(false);
const sshActionMessage = ref("");
const sshActionOk = ref(false);
const sshActionOutput = ref("");
let refreshingLive = false;
useSacLiveEventStream(() => {
@@ -210,6 +237,13 @@ const isWindowsHost = computed(() => {
return h.product === "rdp-login-monitor";
});
const isLinuxHost = computed(() => {
const h = host.value;
if (!h) return false;
if ((h.os_family || "").toLowerCase() === "linux") return true;
return h.product === "ssh-monitor";
});
const inventory = computed(() => host.value?.inventory ?? null);
const windowsFields = computed(() => {
@@ -289,6 +323,8 @@ async function loadHost() {
loading.value = true;
error.value = "";
winRmTestMessage.value = "";
sshActionMessage.value = "";
sshActionOutput.value = "";
try {
host.value = await fetchHost(hostId.value);
} catch (e) {
@@ -313,6 +349,57 @@ async function runWinRmTest() {
}
}
function formatSshOutput(result: { stdout: string | null; stderr: string | null; exit_code: number | null }) {
const parts: string[] = [];
if (result.stdout?.trim()) {
parts.push(result.stdout.trim());
}
if (result.stderr?.trim()) {
parts.push(result.stderr.trim());
}
if (result.exit_code != null) {
parts.push(`exit code: ${result.exit_code}`);
}
return parts.join("\n\n");
}
async function runSshTest() {
testingSsh.value = true;
sshActionMessage.value = "";
sshActionOutput.value = "";
try {
const result = await testHostSsh(hostId.value);
sshActionOk.value = result.ok;
sshActionMessage.value = result.message;
sshActionOutput.value = formatSshOutput(result);
} catch (e) {
sshActionOk.value = false;
sshActionMessage.value = e instanceof Error ? e.message : "Ошибка проверки SSH";
} finally {
testingSsh.value = false;
}
}
async function runAgentUpdate() {
updatingAgent.value = true;
sshActionMessage.value = "";
sshActionOutput.value = "";
try {
const result = await updateHostAgentViaSsh(hostId.value);
sshActionOk.value = result.ok;
sshActionMessage.value = result.message;
sshActionOutput.value = formatSshOutput(result);
if (result.ok) {
host.value = await fetchHost(hostId.value);
}
} catch (e) {
sshActionOk.value = false;
sshActionMessage.value = e instanceof Error ? e.message : "Ошибка обновления агента";
} finally {
updatingAgent.value = false;
}
}
async function refreshFromLatestEvent() {
if (refreshingLive) return;
refreshingLive = true;
@@ -380,4 +467,23 @@ watch(
.muted {
color: #9aa4b2;
}
.host-actions {
margin-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.host-ssh-output {
margin-top: 0.75rem;
max-height: 16rem;
overflow: auto;
padding: 0.75rem;
background: #0d1117;
border-radius: 6px;
font-size: 0.85rem;
white-space: pre-wrap;
}
</style>
+98
View File
@@ -82,6 +82,44 @@
</form>
</section>
<section class="card settings-card settings-policy">
<h2>Linux (SSH / обновление ssh-monitor)</h2>
<p class="settings-hint settings-hint-top">
SSH-учётка для удалённого запуска <code>/opt/scripts/update_ssh_monitor.sh</code> с карточки Linux-хоста.
Рекомендуется <code>root</code> или пользователь с <code>sudo NOPASSWD</code>.
Пока запись не создана в UI используются <code>SAC_LINUX_ADMIN_*</code> из <code>sac-api.env</code>.
</p>
<form class="settings-form" @submit.prevent="saveLinuxAdminSettings">
<label class="settings-field">
SSH login
<input
v-model="linuxAdminForm.user"
type="text"
autocomplete="off"
placeholder="root"
/>
</label>
<label class="settings-field">
SSH password
<input
v-model="linuxAdminForm.password"
type="password"
autocomplete="new-password"
:placeholder="linuxAdminPasswordPlaceholder"
/>
</label>
<p v-if="linuxAdminLoaded" class="settings-meta">
Настроен: {{ linuxAdminLoaded.configured ? "да" : "нет" }}
· источник: <code>{{ linuxAdminLoaded.source }}</code>
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingLinuxAdmin">
{{ savingLinuxAdmin ? "Сохранение…" : "Сохранить Linux admin" }}
</button>
</div>
</form>
</section>
<section class="card settings-card settings-policy">
<h2>Правило оповещений</h2>
<p class="settings-hint settings-hint-top">
@@ -475,12 +513,14 @@ import {
fetchMobileSettings,
fetchUsers,
fetchWinAdminSettings,
fetchLinuxAdminSettings,
revokeMobileDevice,
deleteMobileDevice,
revokeMobileEnrollmentCode,
testMobileDevicePush,
updateMobileSettings,
updateWinAdminSettings,
updateLinuxAdminSettings,
type MobileDevice,
type MobileEnrollmentCode,
type MobileSettings,
@@ -542,6 +582,13 @@ interface WinAdminSettings {
source: string;
}
interface LinuxAdminSettings {
configured: boolean;
user: string | null;
password_hint: string | null;
source: string;
}
interface SeverityOverrideRowApi {
event_type: string;
default_severity: string;
@@ -556,6 +603,7 @@ const loading = ref(true);
const savingPolicy = ref(false);
const savingUi = ref(false);
const savingWinAdmin = ref(false);
const savingLinuxAdmin = ref(false);
const savingTelegram = ref(false);
const savingWebhook = ref(false);
const savingEmail = ref(false);
@@ -574,6 +622,7 @@ 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 linuxAdminLoaded = ref<LinuxAdminSettings | null>(null);
const severityRows = ref<SeverityOverrideRowUi[]>([]);
const severityFilter = ref("");
const mobileLoaded = ref<MobileSettings | null>(null);
@@ -641,6 +690,11 @@ const winAdminForm = reactive({
password: "",
});
const linuxAdminForm = reactive({
user: "",
password: "",
});
const telegramForm = reactive({
enabled: false,
bot_token: "",
@@ -696,6 +750,11 @@ const winAdminPasswordPlaceholder = computed(() =>
? `оставить ${winAdminLoaded.value.password_hint}`
: "обязателен при первой настройке",
);
const linuxAdminPasswordPlaceholder = computed(() =>
linuxAdminLoaded.value?.password_hint
? `оставить ${linuxAdminLoaded.value.password_hint}`
: "обязателен при первой настройке",
);
const filteredSeverityRows = computed(() => {
const q = severityFilter.value.trim().toLowerCase();
@@ -812,6 +871,44 @@ async function saveWinAdminSettings() {
}
}
async function loadLinuxAdminSettings() {
const data = await fetchLinuxAdminSettings();
linuxAdminLoaded.value = data;
linuxAdminForm.user = data.user ?? "";
linuxAdminForm.password = "";
}
async function saveLinuxAdminSettings() {
savingLinuxAdmin.value = true;
error.value = "";
success.value = "";
try {
const payload: { user?: string; password?: string } = {};
if (linuxAdminForm.user.trim()) {
payload.user = linuxAdminForm.user.trim();
}
if (linuxAdminForm.password.trim()) {
payload.password = linuxAdminForm.password;
}
if (!payload.user && !payload.password) {
error.value = "Укажите логин и/или новый пароль Linux admin";
return;
}
if (!linuxAdminLoaded.value?.configured && (!payload.user || !payload.password)) {
error.value = "Для первой настройки укажите логин и пароль";
return;
}
linuxAdminLoaded.value = await updateLinuxAdminSettings(payload);
linuxAdminForm.user = linuxAdminLoaded.value.user ?? linuxAdminForm.user;
linuxAdminForm.password = "";
success.value = "Учётные данные Linux admin сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения Linux admin";
} finally {
savingLinuxAdmin.value = false;
}
}
async function loadMobileSection() {
mobileLoaded.value = await fetchMobileSettings();
mobileForm.devices_allowed = mobileLoaded.value.devices_allowed;
@@ -958,6 +1055,7 @@ async function load() {
emailForm.smtp_ssl = data.email.smtp_ssl;
await loadUiSettings();
await loadWinAdminSettings();
await loadLinuxAdminSettings();
await loadSeverityOverrides();
await loadMobileSection();
} catch (e) {