719d5c5719
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>
818 lines
27 KiB
Python
818 lines
27 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.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 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)
|