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:
@@ -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")
|
||||||
@@ -703,3 +703,115 @@ def test_agent_git_release(
|
|||||||
git_errors=dict(git_release.errors),
|
git_errors=dict(git_release.errors),
|
||||||
from_cache=git_release.from_cache,
|
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)
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ class Settings(BaseSettings):
|
|||||||
sac_login_max_failures: int = 3
|
sac_login_max_failures: int = 3
|
||||||
sac_login_failure_window_minutes: int = 15
|
sac_login_failure_window_minutes: int = 15
|
||||||
sac_login_alert_telegram: bool = True
|
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)
|
# Seaca mobile (FCM push)
|
||||||
sac_fcm_enabled: bool = False
|
sac_fcm_enabled: bool = False
|
||||||
|
|||||||
@@ -33,3 +33,5 @@ class UiSettings(Base):
|
|||||||
agent_git_rdp_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
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_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)
|
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)
|
||||||
|
|||||||
@@ -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})")
|
||||||
@@ -12,6 +12,10 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models.login_attempt import LoginAttempt
|
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
|
from app.services.telegram_notify import send_telegram_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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."""
|
"""Raise 429 if IP exceeded failed login threshold. Returns client IP."""
|
||||||
cfg = get_login_rate_limit_config()
|
cfg = get_login_rate_limit_config()
|
||||||
ip_address = client_ip_from_request(request)
|
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)
|
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
|
||||||
failures = _failure_count(db, ip_address, since=since)
|
failures = _failure_count(db, ip_address, since=since)
|
||||||
if failures >= cfg.max_failures:
|
if failures >= cfg.max_failures:
|
||||||
@@ -119,6 +126,9 @@ def record_login_failure(
|
|||||||
request: Request | None = None,
|
request: Request | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
del request # reserved
|
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()
|
cfg = get_login_rate_limit_config()
|
||||||
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
|
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
|
||||||
prior_failures = _failure_count(db, ip_address, since=since)
|
prior_failures = _failure_count(db, ip_address, since=since)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.4.0"
|
APP_VERSION = "0.4.1"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -103,6 +103,12 @@ SAC_PROBLEMS_RETENTION_DAYS=180
|
|||||||
SAC_LOGIN_MAX_FAILURES=3
|
SAC_LOGIN_MAX_FAILURES=3
|
||||||
SAC_LOGIN_FAILURE_WINDOW_MINUTES=15
|
SAC_LOGIN_FAILURE_WINDOW_MINUTES=15
|
||||||
SAC_LOGIN_ALERT_TELEGRAM=true
|
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)
|
# Seaca mobile push (FCM HTTP v1)
|
||||||
SAC_FCM_ENABLED=false
|
SAC_FCM_ENABLED=false
|
||||||
|
|||||||
@@ -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 {
|
export interface DashboardSummary {
|
||||||
events_last_24h: number;
|
events_last_24h: number;
|
||||||
hosts_total: number;
|
hosts_total: number;
|
||||||
|
|||||||
@@ -44,6 +44,85 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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">
|
<section class="card settings-card settings-policy">
|
||||||
<h2>Windows (qwinsta / logoff)</h2>
|
<h2>Windows (qwinsta / logoff)</h2>
|
||||||
<p class="settings-hint settings-hint-top">
|
<p class="settings-hint settings-hint-top">
|
||||||
@@ -606,6 +685,12 @@ import {
|
|||||||
fetchAgentUpdateSettings,
|
fetchAgentUpdateSettings,
|
||||||
saveAgentUpdateSettings,
|
saveAgentUpdateSettings,
|
||||||
testAgentGitRelease,
|
testAgentGitRelease,
|
||||||
|
fetchLoginSecuritySettings,
|
||||||
|
saveLoginSecuritySettings,
|
||||||
|
fetchLoginSecurityBlocks,
|
||||||
|
unblockLoginSecurityIp,
|
||||||
|
type LoginBlockItem,
|
||||||
|
type LoginSecuritySettings,
|
||||||
revokeMobileDevice,
|
revokeMobileDevice,
|
||||||
deleteMobileDevice,
|
deleteMobileDevice,
|
||||||
revokeMobileEnrollmentCode,
|
revokeMobileEnrollmentCode,
|
||||||
@@ -700,6 +785,9 @@ const savingUi = ref(false);
|
|||||||
const savingWinAdmin = ref(false);
|
const savingWinAdmin = ref(false);
|
||||||
const savingLinuxAdmin = ref(false);
|
const savingLinuxAdmin = ref(false);
|
||||||
const savingAgentUpdate = ref(false);
|
const savingAgentUpdate = ref(false);
|
||||||
|
const savingLoginSecurity = ref(false);
|
||||||
|
const loadingLoginBlocks = ref(false);
|
||||||
|
const unblockingIp = ref("");
|
||||||
const testingAgentGit = ref(false);
|
const testingAgentGit = ref(false);
|
||||||
const agentGitTestErrors = ref("");
|
const agentGitTestErrors = ref("");
|
||||||
const agentGitShowVersions = ref(false);
|
const agentGitShowVersions = ref(false);
|
||||||
@@ -725,6 +813,8 @@ const uiLoaded = ref<UiSettings | null>(null);
|
|||||||
const winAdminLoaded = ref<WinAdminSettings | null>(null);
|
const winAdminLoaded = ref<WinAdminSettings | null>(null);
|
||||||
const linuxAdminLoaded = ref<LinuxAdminSettings | null>(null);
|
const linuxAdminLoaded = ref<LinuxAdminSettings | null>(null);
|
||||||
const agentUpdateLoaded = ref<AgentUpdateSettings | null>(null);
|
const agentUpdateLoaded = ref<AgentUpdateSettings | null>(null);
|
||||||
|
const loginSecurityLoaded = ref<LoginSecuritySettings | null>(null);
|
||||||
|
const loginBlocks = ref<LoginBlockItem[]>([]);
|
||||||
const severityRows = ref<SeverityOverrideRowUi[]>([]);
|
const severityRows = ref<SeverityOverrideRowUi[]>([]);
|
||||||
const severityFilter = ref("");
|
const severityFilter = ref("");
|
||||||
const mobileLoaded = ref<MobileSettings | null>(null);
|
const mobileLoaded = ref<MobileSettings | null>(null);
|
||||||
@@ -787,6 +877,11 @@ const uiForm = reactive({
|
|||||||
show_sidebar_system_stats: true,
|
show_sidebar_system_stats: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loginSecurityForm = reactive({
|
||||||
|
ip_whitelist_text: "",
|
||||||
|
sync_fail2ban: false,
|
||||||
|
});
|
||||||
|
|
||||||
const winAdminForm = reactive({
|
const winAdminForm = reactive({
|
||||||
user: "",
|
user: "",
|
||||||
password: "",
|
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() {
|
async function loadUiSettings() {
|
||||||
const data = await apiFetch<UiSettings>("/api/v1/settings/ui");
|
const data = await apiFetch<UiSettings>("/api/v1/settings/ui");
|
||||||
uiLoaded.value = data;
|
uiLoaded.value = data;
|
||||||
@@ -1270,6 +1426,7 @@ async function load() {
|
|||||||
emailForm.smtp_starttls = data.email.smtp_starttls;
|
emailForm.smtp_starttls = data.email.smtp_starttls;
|
||||||
emailForm.smtp_ssl = data.email.smtp_ssl;
|
emailForm.smtp_ssl = data.email.smtp_ssl;
|
||||||
await loadUiSettings();
|
await loadUiSettings();
|
||||||
|
await loadLoginSecuritySettings();
|
||||||
await loadWinAdminSettings();
|
await loadWinAdminSettings();
|
||||||
await loadLinuxAdminSettings();
|
await loadLinuxAdminSettings();
|
||||||
await loadAgentUpdateSettings();
|
await loadAgentUpdateSettings();
|
||||||
@@ -1476,10 +1633,31 @@ onMounted(() => {
|
|||||||
.settings-field input[type="text"],
|
.settings-field input[type="text"],
|
||||||
.settings-field input[type="password"],
|
.settings-field input[type="password"],
|
||||||
.settings-field input[type="url"],
|
.settings-field input[type="url"],
|
||||||
.settings-field select {
|
.settings-field select,
|
||||||
|
.settings-field textarea {
|
||||||
max-width: 28rem;
|
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 {
|
.settings-policy {
|
||||||
border-color: #3d4f66;
|
border-color: #3d4f66;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user