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)