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,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.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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user