feat: login IP whitelist, blocks UI and fail2ban sync (0.4.1)

Admin settings for web login whitelist, blocked IP list with unblock,
and optional fail2ban ignoreip sync for SSH jail.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 10:06:10 +10:00
parent bda02f67eb
commit 719d5c5719
12 changed files with 863 additions and 2 deletions
+47
View File
@@ -697,6 +697,53 @@ export function testAgentGitRelease(payload?: AgentGitTestPayload): Promise<Agen
});
}
export interface LoginSecuritySettings {
ip_whitelist: string[];
ip_whitelist_text: string;
sync_fail2ban: boolean;
max_failures: number;
window_minutes: number;
fail2ban_available: boolean;
source: string;
}
export interface LoginBlockItem {
ip_address: string;
scope: string;
failure_count: number | null;
usernames: string[];
blocked_until: string | null;
reason: string;
}
export function fetchLoginSecuritySettings(): Promise<LoginSecuritySettings> {
return apiFetch<LoginSecuritySettings>("/api/v1/settings/login-security");
}
export function saveLoginSecuritySettings(payload: {
ip_whitelist_text: string;
sync_fail2ban: boolean;
}): Promise<LoginSecuritySettings> {
return apiFetch<LoginSecuritySettings>("/api/v1/settings/login-security", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export function fetchLoginSecurityBlocks(): Promise<LoginBlockItem[]> {
return apiFetch<LoginBlockItem[]>("/api/v1/settings/login-security/blocks");
}
export function unblockLoginSecurityIp(payload: {
ip_address: string;
scope: "web" | "ssh" | "both";
}): Promise<{ messages: string[] }> {
return apiFetch<{ messages: string[] }>("/api/v1/settings/login-security/unblock", {
method: "POST",
body: JSON.stringify(payload),
});
}
export interface DashboardSummary {
events_last_24h: number;
hosts_total: number;
+179 -1
View File
@@ -44,6 +44,85 @@
</form>
</section>
<section class="card settings-card settings-policy">
<h2>Блокировки входа (UI и SSH)</h2>
<p class="settings-hint settings-hint-top">
<strong>Web UI:</strong> SAC блокирует IP после {{ loginSecurityLoaded?.max_failures ?? 3 }} неудачных
попыток входа за {{ loginSecurityLoaded?.window_minutes ?? 15 }} мин (таблица
<code>login_attempts</code>, ответ HTTP 429).
<strong> SSH</strong> блокирует <code>fail2ban</code> на ОС это отдельно от SAC; типичная причина
неверный пароль при <code>ssh</code>, не путать с паролем UI.
IP из белого списка не учитываются в лимите UI и могут синхронизироваться в fail2ban
(<code>ignoreip</code>), если включена опция ниже.
</p>
<form class="settings-form" @submit.prevent="saveLoginSecuritySettingsHandler">
<label class="settings-field">
Белый список IP (по одному на строку)
<textarea
v-model="loginSecurityForm.ip_whitelist_text"
rows="5"
placeholder="192.168.160.3&#10;192.168.160.42"
spellcheck="false"
/>
</label>
<label class="settings-field settings-inline">
<input v-model="loginSecurityForm.sync_fail2ban" type="checkbox" />
Синхронизировать белый список в fail2ban ignoreip (SSH)
</label>
<p v-if="loginSecurityLoaded" class="settings-meta">
fail2ban: {{ loginSecurityLoaded.fail2ban_available ? "доступен" : "не найден на сервере" }}
· источник whitelist: <code>{{ loginSecurityLoaded.source }}</code>
· лимит UI: {{ loginSecurityLoaded.max_failures }} /
{{ loginSecurityLoaded.window_minutes }} мин
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingLoginSecurity">
{{ savingLoginSecurity ? "Сохранение…" : "Сохранить белый список" }}
</button>
<button type="button" class="secondary" :disabled="loadingLoginBlocks" @click="refreshLoginBlocks">
{{ loadingLoginBlocks ? "Обновление" : "Обновить список блокировок" }}
</button>
</div>
</form>
<h3>Заблокированные IP</h3>
<p v-if="loadingLoginBlocks" class="settings-meta">Загрузка</p>
<p v-else-if="!loginBlocks.length" class="settings-meta">Сейчас блокировок нет (или все IP в белом списке).</p>
<table v-else class="settings-blocks-table">
<thead>
<tr>
<th>IP</th>
<th>Где</th>
<th>Попытки / причина</th>
<th>Логины</th>
<th>До</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in loginBlocks" :key="row.ip_address + row.scope">
<td><code>{{ row.ip_address }}</code></td>
<td>{{ row.scope }}</td>
<td>
<span v-if="row.failure_count != null">{{ row.failure_count }} · </span>{{ row.reason }}
</td>
<td>{{ row.usernames.length ? row.usernames.join(", ") : "—" }}</td>
<td>{{ row.blocked_until ? formatDateTime(row.blocked_until) : "—" }}</td>
<td>
<button
type="button"
class="secondary"
:disabled="unblockingIp === row.ip_address"
@click="unblockIp(row.ip_address, row.scope)"
>
{{ unblockingIp === row.ip_address ? "…" : "Разблокировать" }}
</button>
</td>
</tr>
</tbody>
</table>
</section>
<section class="card settings-card settings-policy">
<h2>Windows (qwinsta / logoff)</h2>
<p class="settings-hint settings-hint-top">
@@ -606,6 +685,12 @@ import {
fetchAgentUpdateSettings,
saveAgentUpdateSettings,
testAgentGitRelease,
fetchLoginSecuritySettings,
saveLoginSecuritySettings,
fetchLoginSecurityBlocks,
unblockLoginSecurityIp,
type LoginBlockItem,
type LoginSecuritySettings,
revokeMobileDevice,
deleteMobileDevice,
revokeMobileEnrollmentCode,
@@ -700,6 +785,9 @@ const savingUi = ref(false);
const savingWinAdmin = ref(false);
const savingLinuxAdmin = ref(false);
const savingAgentUpdate = ref(false);
const savingLoginSecurity = ref(false);
const loadingLoginBlocks = ref(false);
const unblockingIp = ref("");
const testingAgentGit = ref(false);
const agentGitTestErrors = ref("");
const agentGitShowVersions = ref(false);
@@ -725,6 +813,8 @@ const uiLoaded = ref<UiSettings | null>(null);
const winAdminLoaded = ref<WinAdminSettings | null>(null);
const linuxAdminLoaded = ref<LinuxAdminSettings | null>(null);
const agentUpdateLoaded = ref<AgentUpdateSettings | null>(null);
const loginSecurityLoaded = ref<LoginSecuritySettings | null>(null);
const loginBlocks = ref<LoginBlockItem[]>([]);
const severityRows = ref<SeverityOverrideRowUi[]>([]);
const severityFilter = ref("");
const mobileLoaded = ref<MobileSettings | null>(null);
@@ -787,6 +877,11 @@ const uiForm = reactive({
show_sidebar_system_stats: true,
});
const loginSecurityForm = reactive({
ip_whitelist_text: "",
sync_fail2ban: false,
});
const winAdminForm = reactive({
user: "",
password: "",
@@ -926,6 +1021,67 @@ async function saveSeverityOverrides() {
}
}
async function loadLoginSecuritySettings() {
loginSecurityLoaded.value = await fetchLoginSecuritySettings();
loginSecurityForm.ip_whitelist_text = loginSecurityLoaded.value.ip_whitelist_text ?? "";
loginSecurityForm.sync_fail2ban = loginSecurityLoaded.value.sync_fail2ban;
await refreshLoginBlocks();
}
async function saveLoginSecuritySettingsHandler() {
savingLoginSecurity.value = true;
error.value = "";
success.value = "";
try {
loginSecurityLoaded.value = await saveLoginSecuritySettings({
ip_whitelist_text: loginSecurityForm.ip_whitelist_text,
sync_fail2ban: loginSecurityForm.sync_fail2ban,
});
success.value = "Белый список IP сохранён.";
await refreshLoginBlocks();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения login-security";
} finally {
savingLoginSecurity.value = false;
}
}
async function refreshLoginBlocks() {
loadingLoginBlocks.value = true;
try {
loginBlocks.value = await fetchLoginSecurityBlocks();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки блокировок";
} finally {
loadingLoginBlocks.value = false;
}
}
function unblockScopeFromRow(scope: string): "web" | "ssh" | "both" {
const s = scope.toLowerCase();
if (s.includes("web") && s.includes("ssh")) return "both";
if (s.includes("ssh")) return "ssh";
return "web";
}
async function unblockIp(ip: string, scope: string) {
unblockingIp.value = ip;
error.value = "";
success.value = "";
try {
const result = await unblockLoginSecurityIp({
ip_address: ip,
scope: unblockScopeFromRow(scope),
});
success.value = result.messages.join(" · ");
await refreshLoginBlocks();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка разблокировки";
} finally {
unblockingIp.value = "";
}
}
async function loadUiSettings() {
const data = await apiFetch<UiSettings>("/api/v1/settings/ui");
uiLoaded.value = data;
@@ -1270,6 +1426,7 @@ async function load() {
emailForm.smtp_starttls = data.email.smtp_starttls;
emailForm.smtp_ssl = data.email.smtp_ssl;
await loadUiSettings();
await loadLoginSecuritySettings();
await loadWinAdminSettings();
await loadLinuxAdminSettings();
await loadAgentUpdateSettings();
@@ -1476,10 +1633,31 @@ onMounted(() => {
.settings-field input[type="text"],
.settings-field input[type="password"],
.settings-field input[type="url"],
.settings-field select {
.settings-field select,
.settings-field textarea {
max-width: 28rem;
}
.settings-blocks-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.settings-blocks-table th,
.settings-blocks-table td {
border: 1px solid #3d4f66;
padding: 0.35rem 0.5rem;
text-align: left;
vertical-align: top;
}
.settings-blocks-table th {
color: #9aa4b2;
font-weight: 600;
}
.settings-policy {
border-color: #3d4f66;
}