diff --git a/backend/alembic/versions/024_login_security.py b/backend/alembic/versions/024_login_security.py new file mode 100644 index 0000000..a3800a0 --- /dev/null +++ b/backend/alembic/versions/024_login_security.py @@ -0,0 +1,28 @@ +"""login security whitelist in ui_settings + +Revision ID: 024_login_security +Revises: 023_event_type_visibility +Create Date: 2026-06-25 + +""" + +from alembic import op +import sqlalchemy as sa + +revision = "024_login_security" +down_revision = "023_event_type_visibility" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("ui_settings", sa.Column("login_ip_whitelist", sa.Text(), nullable=True)) + op.add_column( + "ui_settings", + sa.Column("login_sync_fail2ban", sa.Boolean(), server_default="false", nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("ui_settings", "login_sync_fail2ban") + op.drop_column("ui_settings", "login_ip_whitelist") diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 258a819..3cd29a7 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -703,3 +703,115 @@ def test_agent_git_release( git_errors=dict(git_release.errors), from_cache=git_release.from_cache, ) + + +class LoginSecuritySettingsResponse(BaseModel): + ip_whitelist: list[str] = Field(default_factory=list) + ip_whitelist_text: str = "" + sync_fail2ban: bool = False + max_failures: int = 3 + window_minutes: int = 15 + fail2ban_available: bool = False + source: str = "default" + + +class LoginSecuritySettingsUpdate(BaseModel): + ip_whitelist_text: str = "" + sync_fail2ban: bool | None = None + + +class LoginBlockItem(BaseModel): + ip_address: str + scope: str + failure_count: int | None = None + usernames: list[str] = Field(default_factory=list) + blocked_until: datetime | None = None + reason: str + + +class LoginUnblockRequest(BaseModel): + ip_address: str + scope: str = Field(default="both", description="web | ssh | both") + + +class LoginUnblockResponse(BaseModel): + messages: list[str] = Field(default_factory=list) + + +def _login_security_response(db: Session) -> LoginSecuritySettingsResponse: + from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings + from app.services.fail2ban_sync import fail2ban_available + from app.services.login_security_settings import get_effective_login_security_config + + cfg = get_effective_login_security_config(db) + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + text = (row.login_ip_whitelist or "") if row is not None else "" + return LoginSecuritySettingsResponse( + ip_whitelist=list(cfg.ip_whitelist), + ip_whitelist_text=text, + sync_fail2ban=cfg.sync_fail2ban, + max_failures=cfg.max_failures, + window_minutes=cfg.window_minutes, + fail2ban_available=fail2ban_available(), + source=cfg.source, + ) + + +def _block_item(entry) -> LoginBlockItem: + return LoginBlockItem( + ip_address=entry.ip_address, + scope=entry.scope, + failure_count=entry.failure_count, + usernames=list(entry.usernames), + blocked_until=entry.blocked_until, + reason=entry.reason, + ) + + +@router.get("/login-security", response_model=LoginSecuritySettingsResponse) +def get_login_security_settings( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> LoginSecuritySettingsResponse: + return _login_security_response(db) + + +@router.put("/login-security", response_model=LoginSecuritySettingsResponse) +def update_login_security_settings( + body: LoginSecuritySettingsUpdate, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> LoginSecuritySettingsResponse: + from app.services.login_security_settings import upsert_login_security_settings + + upsert_login_security_settings( + db, + ip_whitelist_text=body.ip_whitelist_text, + sync_fail2ban=bool(body.sync_fail2ban), + ) + return _login_security_response(db) + + +@router.get("/login-security/blocks", response_model=list[LoginBlockItem]) +def list_login_security_blocks( + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> list[LoginBlockItem]: + from app.services.login_security_settings import list_all_login_blocks + + return [_block_item(e) for e in list_all_login_blocks(db)] + + +@router.post("/login-security/unblock", response_model=LoginUnblockResponse) +def unblock_login_security_ip( + body: LoginUnblockRequest, + db: Session = Depends(get_db), + _user=Depends(require_admin), +) -> LoginUnblockResponse: + from app.services.login_security_settings import unblock_login_ip + + scope = (body.scope or "both").strip().lower() + if scope not in ("web", "ssh", "both"): + raise HTTPException(status_code=422, detail="scope must be web, ssh, or both") + messages = unblock_login_ip(db, body.ip_address, scope=scope) + return LoginUnblockResponse(messages=messages) diff --git a/backend/app/config.py b/backend/app/config.py index 3afa3b9..f416d77 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -141,6 +141,10 @@ class Settings(BaseSettings): sac_login_max_failures: int = 3 sac_login_failure_window_minutes: int = 15 sac_login_alert_telegram: bool = True + sac_login_ip_whitelist: str = "" + sac_fail2ban_ssh_jail: str = "sshd" + sac_fail2ban_sync_enabled: bool = False + sac_fail2ban_ignoreip_file: str = "/etc/fail2ban/jail.d/sac-login-whitelist.local" # Seaca mobile (FCM push) sac_fcm_enabled: bool = False diff --git a/backend/app/models/ui_settings.py b/backend/app/models/ui_settings.py index 165070b..3e35a32 100644 --- a/backend/app/models/ui_settings.py +++ b/backend/app/models/ui_settings.py @@ -33,3 +33,5 @@ class UiSettings(Base): agent_git_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True) agent_git_ssh_version: Mapped[str | None] = mapped_column(String(64), nullable=True) agent_git_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + login_ip_whitelist: Mapped[str | None] = mapped_column(Text, nullable=True) + login_sync_fail2ban: Mapped[bool] = mapped_column(default=False, server_default="false", nullable=False) diff --git a/backend/app/services/fail2ban_sync.py b/backend/app/services/fail2ban_sync.py new file mode 100644 index 0000000..dd6a691 --- /dev/null +++ b/backend/app/services/fail2ban_sync.py @@ -0,0 +1,132 @@ +"""Best-effort integration with fail2ban for SSH login bans (OS level, not SAC DB).""" + +from __future__ import annotations + +import logging +import re +import subprocess +from dataclasses import dataclass + +from app.config import get_settings + +logger = logging.getLogger(__name__) + +_BANNED_IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b") + + +@dataclass(frozen=True) +class Fail2banActionResult: + ok: bool + message: str + + +def _ssh_jail() -> str: + settings = get_settings() + jail = (getattr(settings, "sac_fail2ban_ssh_jail", None) or "sshd").strip() + return jail or "sshd" + + +def _run_fail2ban(args: list[str], *, timeout: int = 15) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["fail2ban-client", *args], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + + +def fail2ban_available() -> bool: + try: + proc = _run_fail2ban(["ping"], timeout=5) + return proc.returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def list_ssh_banned_ips() -> list[str]: + if not fail2ban_available(): + return [] + jail = _ssh_jail() + try: + proc = _run_fail2ban(["status", jail]) + except (OSError, subprocess.SubprocessError) as exc: + logger.warning("fail2ban status failed: %s", exc) + return [] + if proc.returncode != 0: + return [] + text = (proc.stdout or "") + "\n" + (proc.stderr or "") + ips: list[str] = [] + capture = False + for line in text.splitlines(): + lower = line.strip().lower() + if "banned ip list" in lower: + capture = True + tail = line.split(":", 1)[-1] + ips.extend(_BANNED_IP_RE.findall(tail)) + continue + if capture: + if not line.strip(): + break + ips.extend(_BANNED_IP_RE.findall(line)) + # preserve order, unique + seen: set[str] = set() + out: list[str] = [] + for ip in ips: + if ip not in seen: + seen.add(ip) + out.append(ip) + return out + + +def unban_ssh_ip(ip_address: str) -> Fail2banActionResult: + ip = (ip_address or "").strip() + if not ip: + return Fail2banActionResult(ok=False, message="empty ip") + if not fail2ban_available(): + return Fail2banActionResult(ok=False, message="fail2ban-client недоступен на сервере SAC") + jail = _ssh_jail() + try: + proc = _run_fail2ban(["set", jail, "unbanip", ip]) + except (OSError, subprocess.SubprocessError) as exc: + return Fail2banActionResult(ok=False, message=str(exc)) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "unban failed").strip() + return Fail2banActionResult(ok=False, message=err[:500]) + return Fail2banActionResult(ok=True, message=f"SSH: IP {ip} разблокирован (jail {jail})") + + +def sync_fail2ban_ignore_ips(ip_addresses: list[str]) -> Fail2banActionResult: + settings = get_settings() + if not getattr(settings, "sac_fail2ban_sync_enabled", False): + return Fail2banActionResult(ok=True, message="sync отключён (SAC_FAIL2BAN_SYNC_ENABLED=false)") + path = (getattr(settings, "sac_fail2ban_ignoreip_file", None) or "").strip() + if not path: + return Fail2banActionResult(ok=True, message="файл ignoreip не задан (SAC_FAIL2BAN_IGNOREIP_FILE)") + + jail = _ssh_jail() + base_ignore = "127.0.0.1/8 ::1" + extra = " ".join(ip for ip in ip_addresses if ip) + ignore_line = f"{base_ignore} {extra}".strip() + content = ( + f"# Managed by SAC — login IP whitelist for fail2ban\n" + f"[{jail}]\n" + f"ignoreip = {ignore_line}\n" + ) + try: + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + except OSError as exc: + return Fail2banActionResult(ok=False, message=f"не удалось записать {path}: {exc}") + + if not fail2ban_available(): + return Fail2banActionResult(ok=True, message=f"файл записан ({path}); fail2ban reload пропущен") + + try: + proc = _run_fail2ban(["reload"], timeout=30) + except (OSError, subprocess.SubprocessError) as exc: + return Fail2banActionResult(ok=False, message=f"файл записан, reload failed: {exc}") + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "reload failed").strip() + return Fail2banActionResult(ok=False, message=f"файл записан, reload: {err[:300]}") + return Fail2banActionResult(ok=True, message=f"fail2ban ignoreip обновлён ({path})") diff --git a/backend/app/services/login_rate_limit.py b/backend/app/services/login_rate_limit.py index f2fb9a8..ffb95e6 100644 --- a/backend/app/services/login_rate_limit.py +++ b/backend/app/services/login_rate_limit.py @@ -12,6 +12,10 @@ from sqlalchemy.orm import Session from app.config import get_settings from app.models.login_attempt import LoginAttempt +from app.services.login_security_settings import ( + get_effective_login_security_config, + is_ip_login_whitelisted, +) from app.services.telegram_notify import send_telegram_text logger = logging.getLogger(__name__) @@ -91,6 +95,9 @@ def ensure_login_allowed(db: Session, request: Request) -> str: """Raise 429 if IP exceeded failed login threshold. Returns client IP.""" cfg = get_login_rate_limit_config() ip_address = client_ip_from_request(request) + sec = get_effective_login_security_config(db) + if is_ip_login_whitelisted(ip_address, sec.ip_whitelist): + return ip_address since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes) failures = _failure_count(db, ip_address, since=since) if failures >= cfg.max_failures: @@ -119,6 +126,9 @@ def record_login_failure( request: Request | None = None, ) -> None: del request # reserved + sec = get_effective_login_security_config(db) + if is_ip_login_whitelisted(ip_address, sec.ip_whitelist): + return cfg = get_login_rate_limit_config() since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes) prior_failures = _failure_count(db, ip_address, since=since) diff --git a/backend/app/services/login_security_settings.py b/backend/app/services/login_security_settings.py new file mode 100644 index 0000000..1e67629 --- /dev/null +++ b/backend/app/services/login_security_settings.py @@ -0,0 +1,254 @@ +"""SAC UI login security: IP whitelist, blocked list, unblock.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.models.login_attempt import LoginAttempt +from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings +from app.services.fail2ban_sync import ( + Fail2banActionResult, + list_ssh_banned_ips, + sync_fail2ban_ignore_ips, + unban_ssh_ip, +) + +_IP_RE = re.compile( + r"^(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)$" +) + + +@dataclass(frozen=True) +class LoginSecurityConfig: + ip_whitelist: tuple[str, ...] + sync_fail2ban: bool + max_failures: int + window_minutes: int + source: str + + +@dataclass(frozen=True) +class LoginBlockEntry: + ip_address: str + scope: str + failure_count: int | None + usernames: tuple[str, ...] + blocked_until: datetime | None + reason: str + + +def normalize_ip_token(raw: str) -> str | None: + text = (raw or "").strip() + if not text or text.startswith("#"): + return None + if _IP_RE.match(text): + return text + return None + + +def parse_ip_whitelist_text(text: str) -> list[str]: + if not text: + return [] + tokens = re.split(r"[\s,;]+", text.replace("\n", " ")) + out: list[str] = [] + seen: set[str] = set() + for token in tokens: + ip = normalize_ip_token(token) + if ip and ip not in seen: + seen.add(ip) + out.append(ip) + return out + + +def _env_whitelist() -> list[str]: + settings = get_settings() + raw = (getattr(settings, "sac_login_ip_whitelist", None) or "").strip() + return parse_ip_whitelist_text(raw.replace(",", " ")) + + +def _db_whitelist(row: UiSettings | None) -> list[str]: + if row is None: + return [] + return parse_ip_whitelist_text(row.login_ip_whitelist or "") + + +def merge_whitelist(*parts: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for part in parts: + for ip in part: + if ip not in seen: + seen.add(ip) + out.append(ip) + return out + + +def get_effective_login_security_config(db: Session) -> LoginSecurityConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + env_ips = _env_whitelist() + db_ips = _db_whitelist(row) + merged = merge_whitelist(env_ips, db_ips) + settings = get_settings() + max_failures = max(1, int(getattr(settings, "sac_login_max_failures", 3) or 3)) + window_minutes = max(1, int(getattr(settings, "sac_login_failure_window_minutes", 15) or 15)) + sources: list[str] = [] + if env_ips: + sources.append("env") + if db_ips: + sources.append("db") + source = "+".join(sources) if sources else "default" + sync_fb = bool(row.login_sync_fail2ban) if row is not None else False + return LoginSecurityConfig( + ip_whitelist=tuple(merged), + sync_fail2ban=sync_fb, + max_failures=max_failures, + window_minutes=window_minutes, + source=source, + ) + + +def is_ip_login_whitelisted(ip_address: str, whitelist: tuple[str, ...] | list[str]) -> bool: + ip = (ip_address or "").strip() + if not ip: + return False + return ip in whitelist + + +def upsert_login_security_settings( + db: Session, + *, + ip_whitelist_text: str, + sync_fail2ban: bool, +) -> LoginSecurityConfig: + row = db.get(UiSettings, UI_SETTINGS_ROW_ID) + if row is None: + row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True) + db.add(row) + row.login_ip_whitelist = ip_whitelist_text.strip() or None + row.login_sync_fail2ban = bool(sync_fail2ban) + db.commit() + db.refresh(row) + cfg = get_effective_login_security_config(db) + if cfg.sync_fail2ban: + sync_fail2ban_ignore_ips(list(cfg.ip_whitelist)) + return cfg + + +def _window_since(cfg: LoginSecurityConfig) -> datetime: + return datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes) + + +def list_web_login_blocks(db: Session) -> list[LoginBlockEntry]: + cfg = get_effective_login_security_config(db) + since = _window_since(cfg) + rows = db.execute( + select( + LoginAttempt.ip_address, + func.count().label("cnt"), + func.max(LoginAttempt.created_at).label("last_at"), + ) + .where( + LoginAttempt.success.is_(False), + LoginAttempt.username != "__alert_sent__", + LoginAttempt.created_at >= since, + ) + .group_by(LoginAttempt.ip_address) + .having(func.count() >= cfg.max_failures) + ).all() + + blocks: list[LoginBlockEntry] = [] + for ip_address, cnt, last_at in rows: + ip = str(ip_address) + if is_ip_login_whitelisted(ip, cfg.ip_whitelist): + continue + user_rows = db.scalars( + select(LoginAttempt.username) + .where( + LoginAttempt.ip_address == ip, + LoginAttempt.success.is_(False), + LoginAttempt.username.isnot(None), + LoginAttempt.username != "__alert_sent__", + LoginAttempt.created_at >= since, + ) + .distinct() + .limit(10) + ).all() + usernames = tuple(sorted({u for u in user_rows if u})) + blocked_until = (last_at + timedelta(minutes=cfg.window_minutes)) if last_at else None + blocks.append( + LoginBlockEntry( + ip_address=ip, + scope="web", + failure_count=int(cnt or 0), + usernames=usernames, + blocked_until=blocked_until, + reason=f"≥{cfg.max_failures} неудачных входов в UI за {cfg.window_minutes} мин", + ) + ) + return blocks + + +def list_ssh_login_blocks() -> list[LoginBlockEntry]: + banned = list_ssh_banned_ips() + return [ + LoginBlockEntry( + ip_address=ip, + scope="ssh", + failure_count=None, + usernames=(), + blocked_until=None, + reason="fail2ban (sshd)", + ) + for ip in banned + ] + + +def list_all_login_blocks(db: Session) -> list[LoginBlockEntry]: + web = {b.ip_address: b for b in list_web_login_blocks(db)} + ssh = list_ssh_login_blocks() + out = list(web.values()) + for entry in ssh: + if entry.ip_address in web: + existing = web[entry.ip_address] + out[out.index(existing)] = LoginBlockEntry( + ip_address=entry.ip_address, + scope="web+ssh", + failure_count=existing.failure_count, + usernames=existing.usernames, + blocked_until=existing.blocked_until, + reason=f"{existing.reason}; {entry.reason}", + ) + else: + out.append(entry) + out.sort(key=lambda e: e.ip_address) + return out + + +def clear_web_login_block(db: Session, ip_address: str) -> int: + ip = (ip_address or "").strip() + if not ip: + return 0 + result = db.execute(delete(LoginAttempt).where(LoginAttempt.ip_address == ip)) + db.commit() + return int(result.rowcount or 0) + + +def unblock_login_ip(db: Session, ip_address: str, *, scope: str = "both") -> list[str]: + ip = (ip_address or "").strip() + if not ip: + return ["empty ip"] + messages: list[str] = [] + scope_norm = (scope or "both").strip().lower() + if scope_norm in ("web", "both"): + deleted = clear_web_login_block(db, ip) + messages.append(f"Web UI: удалено записей login_attempts: {deleted}") + if scope_norm in ("ssh", "both"): + res: Fail2banActionResult = unban_ssh_ip(ip) + messages.append(res.message) + return messages diff --git a/backend/app/version.py b/backend/app/version.py index dceee1b..e8f1ef3 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.4.0" +APP_VERSION = "0.4.1" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_login_security_settings.py b/backend/tests/test_login_security_settings.py new file mode 100644 index 0000000..4a164de --- /dev/null +++ b/backend/tests/test_login_security_settings.py @@ -0,0 +1,88 @@ +"""Login security whitelist and unblock.""" + +from datetime import datetime, timezone + +from sqlalchemy import delete + +from app.models.login_attempt import LoginAttempt +from app.services.login_security_settings import ( + clear_web_login_block, + get_effective_login_security_config, + is_ip_login_whitelisted, + list_web_login_blocks, + parse_ip_whitelist_text, + upsert_login_security_settings, +) + + +def test_parse_ip_whitelist_text(): + assert parse_ip_whitelist_text("192.168.160.3\n192.168.160.4") == [ + "192.168.160.3", + "192.168.160.4", + ] + assert parse_ip_whitelist_text("192.168.160.3, 10.0.0.1") == [ + "192.168.160.3", + "10.0.0.1", + ] + assert parse_ip_whitelist_text("not-an-ip\n192.168.1.1") == ["192.168.1.1"] + + +def test_whitelisted_ip_not_blocked(client, db_session, monkeypatch): + monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "2") + monkeypatch.setenv("SAC_LOGIN_ALERT_TELEGRAM", "false") + monkeypatch.setattr( + "app.services.login_rate_limit.client_ip_from_request", + lambda _request: "192.168.160.3", + ) + from app.config import get_settings + + get_settings.cache_clear() + db_session.execute(delete(LoginAttempt)) + db_session.commit() + + upsert_login_security_settings( + db_session, + ip_whitelist_text="192.168.160.3", + sync_fail2ban=False, + ) + + for _ in range(5): + r = client.post( + "/api/v1/auth/login", + json={"username": "test-admin", "password": "wrong"}, + ) + assert r.status_code == 401 + + get_settings.cache_clear() + + +def test_list_web_blocks_and_clear(db_session, monkeypatch): + monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "2") + monkeypatch.setenv("SAC_LOGIN_FAILURE_WINDOW_MINUTES", "15") + from app.config import get_settings + + get_settings.cache_clear() + + now = datetime.now(timezone.utc) + db_session.add( + LoginAttempt(ip_address="203.0.113.50", username="bad", success=False, created_at=now) + ) + db_session.add( + LoginAttempt(ip_address="203.0.113.50", username="bad", success=False, created_at=now) + ) + db_session.commit() + + blocks = list_web_login_blocks(db_session) + assert any(b.ip_address == "203.0.113.50" for b in blocks) + + deleted = clear_web_login_block(db_session, "203.0.113.50") + assert deleted >= 2 + assert not list_web_login_blocks(db_session) + + get_settings.cache_clear() + + +def test_is_ip_login_whitelisted(): + wl = ("192.168.160.3",) + assert is_ip_login_whitelisted("192.168.160.3", wl) + assert not is_ip_login_whitelisted("192.168.160.4", wl) diff --git a/deploy/env.native.example b/deploy/env.native.example index 94f4633..f3d26e4 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -103,6 +103,12 @@ SAC_PROBLEMS_RETENTION_DAYS=180 SAC_LOGIN_MAX_FAILURES=3 SAC_LOGIN_FAILURE_WINDOW_MINUTES=15 SAC_LOGIN_ALERT_TELEGRAM=true +# IP через запятую — не блокируются в UI (дополняет список в Настройках SAC) +SAC_LOGIN_IP_WHITELIST=192.168.160.3 +# fail2ban: синхронизация белого списка из UI (нужны права записи + reload) +SAC_FAIL2BAN_SYNC_ENABLED=false +SAC_FAIL2BAN_SSH_JAIL=sshd +SAC_FAIL2BAN_IGNOREIP_FILE=/etc/fail2ban/jail.d/sac-login-whitelist.local # Seaca mobile push (FCM HTTP v1) SAC_FCM_ENABLED=false diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e4f7da0..d79195c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -697,6 +697,53 @@ export function testAgentGitRelease(payload?: AgentGitTestPayload): Promise { + return apiFetch("/api/v1/settings/login-security"); +} + +export function saveLoginSecuritySettings(payload: { + ip_whitelist_text: string; + sync_fail2ban: boolean; +}): Promise { + return apiFetch("/api/v1/settings/login-security", { + method: "PUT", + body: JSON.stringify(payload), + }); +} + +export function fetchLoginSecurityBlocks(): Promise { + return apiFetch("/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; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 32ab1a3..11f1e6c 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -44,6 +44,85 @@ +
+

Блокировки входа (UI и SSH)

+

+ Web UI: SAC блокирует IP после {{ loginSecurityLoaded?.max_failures ?? 3 }} неудачных + попыток входа за {{ loginSecurityLoaded?.window_minutes ?? 15 }} мин (таблица + login_attempts, ответ HTTP 429). + SSH блокирует fail2ban на ОС — это отдельно от SAC; типичная причина — + неверный пароль при ssh, не путать с паролем UI. + IP из белого списка не учитываются в лимите UI и могут синхронизироваться в fail2ban + (ignoreip), если включена опция ниже. +

+
+