3765d4d476
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>
861 lines
28 KiB
Python
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)
|