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:
@@ -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;
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user