Files
security-alert-center/backend/app/api/v1/settings.py
T
PapaTramp 3765d4d476 feat: RDP flap auto-disconnect and hide break button after reconnect (0.4.10)
Add optional auto logoff of stuck sessions on RDG flap and direct RDP failure. Hide the RDS break button on old flap 302 when the user later reconnects or the workstation session is closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:50:03 +10:00

861 lines
28 KiB
Python

from dataclasses import replace
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.jwt_auth import require_admin
from app.database import get_db
from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message
from app.services.notification_policy import (
NotificationPolicyConfig,
get_effective_notification_policy,
upsert_notification_policy,
)
from app.services.notification_settings import (
VALID_SEVERITIES,
EmailConfig,
TelegramConfig,
WebhookConfig,
get_effective_email_config,
get_effective_telegram_config,
get_effective_webhook_config,
upsert_email_channel,
upsert_telegram_channel,
upsert_webhook_channel,
)
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
from app.services.webhook_notify import (
WebhookNotConfiguredError,
WebhookSendError,
send_webhook_test_message,
)
from app.services.event_severity_overrides import (
SOURCE_DB,
list_severity_override_items,
replace_severity_overrides,
)
from app.services.event_type_visibility import replace_event_visibility
from app.services.ui_settings import (
get_effective_ui_settings,
upsert_ui_settings,
)
from app.services.linux_admin_settings import (
get_effective_linux_admin_config,
upsert_linux_admin_settings,
)
from app.services.win_admin_settings import (
get_effective_win_admin_config,
upsert_win_admin_settings,
)
from app.services.rdp_flap_settings import (
get_effective_rdp_flap_settings,
upsert_rdp_flap_settings,
)
from app.services.agent_git_release import get_git_release_versions
from app.services.agent_update_settings import (
get_effective_agent_update_config,
upsert_agent_update_settings,
)
from app.services.winrm_connect import (
HostNotWindowsError,
HostTargetMissingError,
WinAdminNotConfiguredError,
resolve_windows_host_target,
test_winrm_connection,
)
router = APIRouter(prefix="/settings", tags=["settings"])
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
text = value.strip()
if not text:
return None
if len(text) <= visible_tail:
return "*" * len(text)
return f"{'*' * 8}{text[-visible_tail:]}"
def _mask_url(value: str) -> str | None:
text = value.strip()
if not text:
return None
if len(text) <= 20:
return f"{'*' * 6}{text[-4:]}"
return f"{text[:16]}{text[-6:]}"
class TelegramSettingsResponse(BaseModel):
enabled: bool
configured: bool
chat_id_hint: str | None = None
bot_token_hint: str | None = None
min_severity: str
source: str = Field(description="env или db")
class WebhookSettingsResponse(BaseModel):
enabled: bool
configured: bool
url_hint: str | None = None
secret_header: str | None = None
secret_hint: str | None = None
min_severity: str
source: str = Field(description="env или db")
class EmailSettingsResponse(BaseModel):
enabled: bool
configured: bool
smtp_host_hint: str | None = None
smtp_port: int
smtp_user: str | None = None
smtp_password_hint: str | None = None
mail_from: str | None = None
mail_to_hint: str | None = None
smtp_starttls: bool
smtp_ssl: bool
min_severity: str
source: str = Field(description="env или db")
class NotificationPolicyResponse(BaseModel):
min_severity: str
use_telegram: bool
use_webhook: bool
use_email: bool
use_mobile: bool
source: str = Field(description="env или db")
class NotificationSettingsResponse(BaseModel):
policy: NotificationPolicyResponse
telegram: TelegramSettingsResponse
webhook: WebhookSettingsResponse
email: EmailSettingsResponse
class NotificationPolicyUpdate(BaseModel):
min_severity: str
use_telegram: bool
use_webhook: bool
use_email: bool
use_mobile: bool = False
class TelegramSettingsUpdate(BaseModel):
enabled: bool
bot_token: str | None = None
chat_id: str | None = None
class WebhookSettingsUpdate(BaseModel):
enabled: bool
url: str | None = None
secret_header: str | None = None
secret: str | None = None
class EmailSettingsUpdate(BaseModel):
enabled: bool
smtp_host: str | None = None
smtp_port: int | None = None
smtp_user: str | None = None
smtp_password: str | None = None
mail_from: str | None = None
mail_to: str | None = None
smtp_starttls: bool | None = None
smtp_ssl: bool | None = None
class ChannelTestResponse(BaseModel):
ok: bool = True
message: str
def _policy_to_response(cfg: NotificationPolicyConfig) -> NotificationPolicyResponse:
return NotificationPolicyResponse(
min_severity=cfg.min_severity,
use_telegram=cfg.use_telegram,
use_webhook=cfg.use_webhook,
use_email=cfg.use_email,
use_mobile=cfg.use_mobile,
source=cfg.source,
)
def _telegram_to_response(cfg: TelegramConfig, policy: NotificationPolicyConfig) -> TelegramSettingsResponse:
return TelegramSettingsResponse(
enabled=cfg.enabled,
configured=cfg.configured,
chat_id_hint=_mask_secret(cfg.chat_id),
bot_token_hint=_mask_secret(cfg.bot_token),
min_severity=policy.min_severity,
source=cfg.source,
)
def _webhook_to_response(cfg: WebhookConfig, policy: NotificationPolicyConfig) -> WebhookSettingsResponse:
return WebhookSettingsResponse(
enabled=cfg.enabled,
configured=cfg.configured,
url_hint=_mask_url(cfg.url),
secret_header=cfg.secret_header or None,
secret_hint=_mask_secret(cfg.secret),
min_severity=policy.min_severity,
source=cfg.source,
)
def _email_to_response(cfg: EmailConfig, policy: NotificationPolicyConfig) -> EmailSettingsResponse:
return EmailSettingsResponse(
enabled=cfg.enabled,
configured=cfg.configured,
smtp_host_hint=_mask_url(cfg.smtp_host) if cfg.smtp_host else None,
smtp_port=cfg.smtp_port,
smtp_user=cfg.smtp_user or None,
smtp_password_hint=_mask_secret(cfg.smtp_password),
mail_from=cfg.mail_from or None,
mail_to_hint=_mask_secret(cfg.mail_to.replace(";", ",")) if cfg.mail_to else None,
smtp_starttls=cfg.smtp_starttls,
smtp_ssl=cfg.smtp_ssl,
min_severity=policy.min_severity,
source=cfg.source,
)
@router.get("/notifications", response_model=NotificationSettingsResponse)
def get_notification_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> NotificationSettingsResponse:
policy = get_effective_notification_policy(db)
return NotificationSettingsResponse(
policy=_policy_to_response(policy),
telegram=_telegram_to_response(get_effective_telegram_config(db), policy),
webhook=_webhook_to_response(get_effective_webhook_config(db), policy),
email=_email_to_response(get_effective_email_config(db), policy),
)
@router.put("/notifications/policy", response_model=NotificationPolicyResponse)
def update_notification_policy(
body: NotificationPolicyUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> NotificationPolicyResponse:
if body.min_severity not in VALID_SEVERITIES:
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
if not any((body.use_telegram, body.use_webhook, body.use_email, body.use_mobile)):
raise HTTPException(status_code=422, detail="Выберите хотя бы один канал")
try:
policy = upsert_notification_policy(
db,
min_severity=body.min_severity,
use_telegram=body.use_telegram,
use_webhook=body.use_webhook,
use_email=body.use_email,
use_mobile=body.use_mobile,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return _policy_to_response(policy)
@router.put("/notifications/telegram", response_model=TelegramSettingsResponse)
def update_telegram_settings(
body: TelegramSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> TelegramSettingsResponse:
try:
cfg = upsert_telegram_channel(
db,
enabled=body.enabled,
bot_token=body.bot_token,
chat_id=body.chat_id,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
policy = get_effective_notification_policy(db)
return _telegram_to_response(cfg, policy)
@router.put("/notifications/webhook", response_model=WebhookSettingsResponse)
def update_webhook_settings(
body: WebhookSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> WebhookSettingsResponse:
try:
cfg = upsert_webhook_channel(
db,
enabled=body.enabled,
url=body.url,
secret_header=body.secret_header,
secret=body.secret,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
policy = get_effective_notification_policy(db)
return _webhook_to_response(cfg, policy)
@router.put("/notifications/email", response_model=EmailSettingsResponse)
def update_email_settings(
body: EmailSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> EmailSettingsResponse:
try:
cfg = upsert_email_channel(
db,
enabled=body.enabled,
smtp_host=body.smtp_host,
smtp_port=body.smtp_port,
smtp_user=body.smtp_user,
smtp_password=body.smtp_password,
mail_from=body.mail_from,
mail_to=body.mail_to,
smtp_starttls=body.smtp_starttls,
smtp_ssl=body.smtp_ssl,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
policy = get_effective_notification_policy(db)
return _email_to_response(cfg, policy)
@router.post("/notifications/telegram/test", response_model=ChannelTestResponse)
def test_telegram_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> ChannelTestResponse:
cfg = get_effective_telegram_config(db)
try:
send_telegram_test_message(config=cfg)
except TelegramNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except TelegramSendError as exc:
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
raise HTTPException(status_code=code, detail=str(exc)) from exc
return ChannelTestResponse(message="Тестовое сообщение отправлено в Telegram")
@router.post("/notifications/webhook/test", response_model=ChannelTestResponse)
def test_webhook_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> ChannelTestResponse:
cfg = get_effective_webhook_config(db)
try:
send_webhook_test_message(config=cfg)
except WebhookNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except WebhookSendError as exc:
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
raise HTTPException(status_code=code, detail=str(exc)) from exc
return ChannelTestResponse(message="Тестовый POST отправлен на webhook URL")
@router.post("/notifications/email/test", response_model=ChannelTestResponse)
def test_email_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> ChannelTestResponse:
cfg = get_effective_email_config(db)
try:
send_email_test_message(config=cfg)
except EmailNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except EmailSendError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP")
class EventSeverityOverrideRow(BaseModel):
event_type: str
default_severity: str
override_severity: str | None = None
show_in_events: bool = True
class EventSeverityOverridesResponse(BaseModel):
items: list[EventSeverityOverrideRow]
source: str = Field(description="db — overrides хранятся в PostgreSQL")
class EventSeverityOverridesUpdate(BaseModel):
overrides: dict[str, str | None] = Field(
default_factory=dict,
description="event_type → severity; null или пустая строка сбрасывает override",
)
visibility: dict[str, bool | None] = Field(
default_factory=dict,
description="event_type → show_in_events; null или true сбрасывает скрытие (показывать)",
)
def _severity_override_rows(items) -> list[EventSeverityOverrideRow]:
return [
EventSeverityOverrideRow(
event_type=i.event_type,
default_severity=i.default_severity,
override_severity=i.override_severity,
show_in_events=i.show_in_events,
)
for i in items
]
@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
def get_severity_overrides(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> EventSeverityOverridesResponse:
items = list_severity_override_items(db)
return EventSeverityOverridesResponse(
items=_severity_override_rows(items),
source=SOURCE_DB,
)
@router.put("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
def update_severity_overrides(
body: EventSeverityOverridesUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> EventSeverityOverridesResponse:
try:
items = list_severity_override_items(db)
if body.overrides:
items = replace_severity_overrides(db, body.overrides)
if body.visibility:
replace_event_visibility(db, body.visibility)
items = list_severity_override_items(db)
if not body.overrides and not body.visibility:
raise HTTPException(status_code=422, detail="Нет изменений для сохранения")
db.commit()
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return EventSeverityOverridesResponse(
items=_severity_override_rows(items),
source=SOURCE_DB,
)
class UiSettingsResponse(BaseModel):
show_sidebar_system_stats: bool
source: str = Field(description="db или default")
class UiSettingsUpdate(BaseModel):
show_sidebar_system_stats: bool
@router.get("/ui", response_model=UiSettingsResponse)
def get_ui_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> UiSettingsResponse:
cfg = get_effective_ui_settings(db)
return UiSettingsResponse(
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
source=cfg.source,
)
@router.put("/ui", response_model=UiSettingsResponse)
def update_ui_settings(
body: UiSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> UiSettingsResponse:
cfg = upsert_ui_settings(db, show_sidebar_system_stats=body.show_sidebar_system_stats)
return UiSettingsResponse(
show_sidebar_system_stats=cfg.show_sidebar_system_stats,
source=cfg.source,
)
class WinAdminSettingsResponse(BaseModel):
configured: bool
user: str | None = None
password_hint: str | None = None
source: str = Field(description="env или db")
class WinAdminSettingsUpdate(BaseModel):
user: str | None = None
password: str | None = None
@router.get("/win-admin", response_model=WinAdminSettingsResponse)
def get_win_admin_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> WinAdminSettingsResponse:
cfg = get_effective_win_admin_config(db)
return WinAdminSettingsResponse(
configured=cfg.configured,
user=cfg.user or None,
password_hint=_mask_secret(cfg.password),
source=cfg.source,
)
@router.put("/win-admin", response_model=WinAdminSettingsResponse)
def update_win_admin_settings(
body: WinAdminSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> WinAdminSettingsResponse:
if body.user is not None and not body.user.strip():
raise HTTPException(status_code=422, detail="user must not be empty")
cfg = upsert_win_admin_settings(db, user=body.user, password=body.password)
return WinAdminSettingsResponse(
configured=cfg.configured,
user=cfg.user or None,
password_hint=_mask_secret(cfg.password),
source=cfg.source,
)
class RdpFlapSettingsResponse(BaseModel):
auto_disconnect: bool
win_admin_configured: bool
source: str = Field(description="db или default")
class RdpFlapSettingsUpdate(BaseModel):
auto_disconnect: bool
@router.get("/rdp-flap", response_model=RdpFlapSettingsResponse)
def get_rdp_flap_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> RdpFlapSettingsResponse:
cfg = get_effective_rdp_flap_settings(db)
win_cfg = get_effective_win_admin_config(db)
return RdpFlapSettingsResponse(
auto_disconnect=cfg.auto_disconnect,
win_admin_configured=win_cfg.configured,
source=cfg.source,
)
@router.put("/rdp-flap", response_model=RdpFlapSettingsResponse)
def update_rdp_flap_settings(
body: RdpFlapSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> RdpFlapSettingsResponse:
cfg = upsert_rdp_flap_settings(db, auto_disconnect=body.auto_disconnect)
win_cfg = get_effective_win_admin_config(db)
return RdpFlapSettingsResponse(
auto_disconnect=cfg.auto_disconnect,
win_admin_configured=win_cfg.configured,
source=cfg.source,
)
class LinuxAdminSettingsResponse(BaseModel):
configured: bool
user: str | None = None
password_hint: str | None = None
source: str = Field(description="env или db")
class LinuxAdminSettingsUpdate(BaseModel):
user: str | None = None
password: str | None = None
@router.get("/linux-admin", response_model=LinuxAdminSettingsResponse)
def get_linux_admin_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> LinuxAdminSettingsResponse:
cfg = get_effective_linux_admin_config(db)
return LinuxAdminSettingsResponse(
configured=cfg.configured,
user=cfg.user or None,
password_hint=_mask_secret(cfg.password),
source=cfg.source,
)
@router.put("/linux-admin", response_model=LinuxAdminSettingsResponse)
def update_linux_admin_settings(
body: LinuxAdminSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> LinuxAdminSettingsResponse:
if body.user is not None and not body.user.strip():
raise HTTPException(status_code=422, detail="user must not be empty")
cfg = upsert_linux_admin_settings(db, user=body.user, password=body.password)
return LinuxAdminSettingsResponse(
configured=cfg.configured,
user=cfg.user or None,
password_hint=_mask_secret(cfg.password),
source=cfg.source,
)
class AgentUpdateSettingsResponse(BaseModel):
mode: str
fallback_enabled: bool
fallback_after_minutes: int
recommended_rdp_version: str | None = None
recommended_ssh_version: str | None = None
min_rdp_version: str | None = None
min_ssh_version: str | None = None
win_agent_update_script: str | None = None
rdp_git_repo_url: str | None = None
ssh_git_repo_url: str | None = None
git_branch: str = "main"
git_latest_rdp_version: str | None = None
git_latest_ssh_version: str | None = None
git_fetched_at: datetime | None = None
git_errors: dict[str, str] = Field(default_factory=dict)
source: str = Field(description="env или db")
class AgentUpdateSettingsUpdate(BaseModel):
mode: str | None = None
fallback_enabled: bool | None = None
fallback_after_minutes: int | None = None
recommended_rdp_version: str | None = None
recommended_ssh_version: str | None = None
min_rdp_version: str | None = None
min_ssh_version: str | None = None
win_agent_update_script: str | None = None
rdp_git_repo_url: str | None = None
ssh_git_repo_url: str | None = None
git_branch: str | None = None
class AgentGitTestRequest(BaseModel):
rdp_git_repo_url: str | None = None
ssh_git_repo_url: str | None = None
git_branch: str | None = None
class AgentGitTestResponse(BaseModel):
git_latest_rdp_version: str | None = None
git_latest_ssh_version: str | None = None
git_fetched_at: datetime | None = None
git_errors: dict[str, str] = Field(default_factory=dict)
from_cache: bool = False
def _agent_update_to_response(cfg, git_release=None) -> AgentUpdateSettingsResponse:
git_latest_rdp = None
git_latest_ssh = None
git_fetched_at = None
git_errors: dict[str, str] = {}
if git_release is not None:
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
git_latest_rdp = git_release.versions.get(PRODUCT_RDP) or None
git_latest_ssh = git_release.versions.get(PRODUCT_SSH) or None
git_fetched_at = git_release.fetched_at
git_errors = dict(git_release.errors)
return AgentUpdateSettingsResponse(
mode=cfg.mode,
fallback_enabled=cfg.fallback_enabled,
fallback_after_minutes=cfg.fallback_after_minutes,
recommended_rdp_version=cfg.recommended_rdp_version or None,
recommended_ssh_version=cfg.recommended_ssh_version or None,
min_rdp_version=cfg.min_rdp_version or None,
min_ssh_version=cfg.min_ssh_version or None,
win_agent_update_script=cfg.win_agent_update_script or None,
rdp_git_repo_url=cfg.rdp_git_repo_url or None,
ssh_git_repo_url=cfg.ssh_git_repo_url or None,
git_branch=cfg.git_branch or "main",
git_latest_rdp_version=git_latest_rdp,
git_latest_ssh_version=git_latest_ssh,
git_fetched_at=git_fetched_at,
git_errors=git_errors,
source=cfg.source,
)
@router.get("/agent-updates", response_model=AgentUpdateSettingsResponse)
def get_agent_update_settings(
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> AgentUpdateSettingsResponse:
cfg = get_effective_agent_update_config(db)
git_release = get_git_release_versions(cfg, db=db)
return _agent_update_to_response(cfg, git_release)
@router.put("/agent-updates", response_model=AgentUpdateSettingsResponse)
def update_agent_update_settings(
body: AgentUpdateSettingsUpdate,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> AgentUpdateSettingsResponse:
try:
cfg = upsert_agent_update_settings(
db,
mode=body.mode,
fallback_enabled=body.fallback_enabled,
fallback_after_minutes=body.fallback_after_minutes,
recommended_rdp_version=body.recommended_rdp_version,
recommended_ssh_version=body.recommended_ssh_version,
min_rdp_version=body.min_rdp_version,
min_ssh_version=body.min_ssh_version,
win_agent_update_script=body.win_agent_update_script,
rdp_git_repo_url=body.rdp_git_repo_url,
ssh_git_repo_url=body.ssh_git_repo_url,
git_branch=body.git_branch,
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
return _agent_update_to_response(cfg, git_release)
@router.post("/agent-updates/test-git", response_model=AgentGitTestResponse)
def test_agent_git_release(
body: AgentGitTestRequest | None = None,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> AgentGitTestResponse:
cfg = get_effective_agent_update_config(db)
if body is not None:
cfg = replace(
cfg,
rdp_git_repo_url=(body.rdp_git_repo_url or cfg.rdp_git_repo_url).strip(),
ssh_git_repo_url=(body.ssh_git_repo_url or cfg.ssh_git_repo_url).strip(),
git_branch=(body.git_branch or cfg.git_branch or "main").strip() or "main",
)
git_release = get_git_release_versions(cfg, db=db, force_refresh=True)
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
return AgentGitTestResponse(
git_latest_rdp_version=git_release.versions.get(PRODUCT_RDP) or None,
git_latest_ssh_version=git_release.versions.get(PRODUCT_SSH) or None,
git_fetched_at=git_release.fetched_at,
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)