feat: Linux SSH admin and remote ssh-monitor update (0.11.0)
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
"""ui_settings: Linux SSH admin for remote agent updates
|
||||||
|
|
||||||
|
Revision ID: 018
|
||||||
|
Revises: 017
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "018"
|
||||||
|
down_revision: Union[str, None] = "017"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("ui_settings", sa.Column("linux_admin_user", sa.String(128), nullable=True))
|
||||||
|
op.add_column("ui_settings", sa.Column("linux_admin_password", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ui_settings", "linux_admin_password")
|
||||||
|
op.drop_column("ui_settings", "linux_admin_user")
|
||||||
@@ -10,6 +10,7 @@ from app.models import Event, Host
|
|||||||
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
||||||
from app.services.agent_version import latest_agent_versions_by_product
|
from app.services.agent_version import latest_agent_versions_by_product
|
||||||
from app.services.host_delete import delete_host_and_related
|
from app.services.host_delete import delete_host_and_related
|
||||||
|
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||||
from app.services.winrm_connect import (
|
from app.services.winrm_connect import (
|
||||||
HostNotWindowsError,
|
HostNotWindowsError,
|
||||||
@@ -19,6 +20,15 @@ from app.services.winrm_connect import (
|
|||||||
iter_winrm_targets,
|
iter_winrm_targets,
|
||||||
test_winrm_connection,
|
test_winrm_connection,
|
||||||
)
|
)
|
||||||
|
from app.services.ssh_connect import (
|
||||||
|
HostNotLinuxError as SshHostNotLinuxError,
|
||||||
|
HostTargetMissingError as SshHostTargetMissingError,
|
||||||
|
LinuxAdminNotConfiguredError,
|
||||||
|
SshCommandResult,
|
||||||
|
iter_ssh_targets,
|
||||||
|
run_ssh_monitor_update,
|
||||||
|
test_ssh_connection,
|
||||||
|
)
|
||||||
from app.services.host_health import (
|
from app.services.host_health import (
|
||||||
HEARTBEAT_TYPE,
|
HEARTBEAT_TYPE,
|
||||||
agent_status as compute_agent_status,
|
agent_status as compute_agent_status,
|
||||||
@@ -172,6 +182,60 @@ class HostWinRmTestResponse(BaseModel):
|
|||||||
hostname: str | None = None
|
hostname: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostSshActionResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
target: str
|
||||||
|
stdout: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
exit_code: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_action_response(result: SshCommandResult, *, attempts: list[str] | None = None) -> HostSshActionResponse:
|
||||||
|
message = result.message
|
||||||
|
if attempts:
|
||||||
|
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||||
|
return HostSshActionResponse(
|
||||||
|
ok=result.ok,
|
||||||
|
message=message,
|
||||||
|
target=result.target,
|
||||||
|
stdout=result.stdout or None,
|
||||||
|
stderr=result.stderr or None,
|
||||||
|
exit_code=result.exit_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ssh_action_on_host(
|
||||||
|
host: Host,
|
||||||
|
cfg,
|
||||||
|
*,
|
||||||
|
action,
|
||||||
|
) -> HostSshActionResponse:
|
||||||
|
try:
|
||||||
|
targets = iter_ssh_targets(host)
|
||||||
|
except SshHostNotLinuxError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except SshHostTargetMissingError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
last_result: SshCommandResult | None = None
|
||||||
|
attempts: list[str] = []
|
||||||
|
for target in targets:
|
||||||
|
try:
|
||||||
|
result = action(target=target, user=cfg.user, password=cfg.password)
|
||||||
|
except LinuxAdminNotConfiguredError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
last_result = result
|
||||||
|
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
|
||||||
|
if result.ok:
|
||||||
|
return _ssh_action_response(result, attempts=attempts if len(attempts) > 1 else None)
|
||||||
|
|
||||||
|
assert last_result is not None
|
||||||
|
return _ssh_action_response(last_result, attempts=attempts if len(attempts) > 1 else None)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse)
|
@router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse)
|
||||||
def test_host_winrm(
|
def test_host_winrm(
|
||||||
host_id: int,
|
host_id: int,
|
||||||
@@ -235,6 +299,40 @@ def test_host_winrm(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{host_id}/actions/ssh-test", response_model=HostSshActionResponse)
|
||||||
|
def test_host_ssh(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostSshActionResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
|
cfg = get_effective_linux_admin_config(db)
|
||||||
|
if not cfg.configured:
|
||||||
|
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
||||||
|
|
||||||
|
return _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{host_id}/actions/agent-update", response_model=HostSshActionResponse)
|
||||||
|
def update_host_agent_via_ssh(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostSshActionResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
|
cfg = get_effective_linux_admin_config(db)
|
||||||
|
if not cfg.configured:
|
||||||
|
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
||||||
|
|
||||||
|
return _run_ssh_action_on_host(host, cfg, action=run_ssh_monitor_update)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{host_id}", response_model=HostDeleteResponse)
|
@router.delete("/{host_id}", response_model=HostDeleteResponse)
|
||||||
def delete_host(
|
def delete_host(
|
||||||
host_id: int,
|
host_id: int,
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ from app.services.ui_settings import (
|
|||||||
get_effective_ui_settings,
|
get_effective_ui_settings,
|
||||||
upsert_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 (
|
from app.services.win_admin_settings import (
|
||||||
get_effective_win_admin_config,
|
get_effective_win_admin_config,
|
||||||
upsert_win_admin_settings,
|
upsert_win_admin_settings,
|
||||||
@@ -493,3 +497,46 @@ def update_win_admin_settings(
|
|||||||
password_hint=_mask_secret(cfg.password),
|
password_hint=_mask_secret(cfg.password),
|
||||||
source=cfg.source,
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ class Settings(BaseSettings):
|
|||||||
sac_win_admin_user: str = ""
|
sac_win_admin_user: str = ""
|
||||||
sac_win_admin_password: str = ""
|
sac_win_admin_password: str = ""
|
||||||
|
|
||||||
|
# Linux SSH admin for remote agent update (fallback SSH, phase 5)
|
||||||
|
sac_linux_admin_user: str = ""
|
||||||
|
sac_linux_admin_password: str = ""
|
||||||
|
|
||||||
# Retention (app.jobs.retention / systemd timer)
|
# Retention (app.jobs.retention / systemd timer)
|
||||||
sac_events_retention_days: int = 90
|
sac_events_retention_days: int = 90
|
||||||
sac_problems_retention_days: int = 180
|
sac_problems_retention_days: int = 180
|
||||||
|
|||||||
@@ -15,3 +15,5 @@ class UiSettings(Base):
|
|||||||
show_sidebar_system_stats: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
show_sidebar_system_stats: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
win_admin_user: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
win_admin_user: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
win_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
win_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
linux_admin_user: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
linux_admin_password: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Effective Linux SSH admin credentials (DB overrides env)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LinuxAdminConfig:
|
||||||
|
user: str
|
||||||
|
password: str
|
||||||
|
source: str # env | db
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self.user.strip() and self.password.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _linux_admin_from_env() -> LinuxAdminConfig:
|
||||||
|
settings = get_settings()
|
||||||
|
return LinuxAdminConfig(
|
||||||
|
user=settings.sac_linux_admin_user.strip(),
|
||||||
|
password=settings.sac_linux_admin_password,
|
||||||
|
source="env",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_linux_admin_config(db: Session | None = None) -> LinuxAdminConfig:
|
||||||
|
if db is None:
|
||||||
|
from app.database import SessionLocal
|
||||||
|
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
return get_effective_linux_admin_config(session)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
|
env_cfg = _linux_admin_from_env()
|
||||||
|
if row is None:
|
||||||
|
return env_cfg
|
||||||
|
|
||||||
|
user = (row.linux_admin_user or "").strip() or env_cfg.user
|
||||||
|
password = (row.linux_admin_password or "") or env_cfg.password
|
||||||
|
has_db_values = bool((row.linux_admin_user or "").strip() or (row.linux_admin_password or "").strip())
|
||||||
|
return LinuxAdminConfig(
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
source="db" if has_db_values else env_cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_linux_admin_settings(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
) -> LinuxAdminConfig:
|
||||||
|
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
|
env_cfg = _linux_admin_from_env()
|
||||||
|
if row is None:
|
||||||
|
row = UiSettings(
|
||||||
|
id=UI_SETTINGS_ROW_ID,
|
||||||
|
show_sidebar_system_stats=True,
|
||||||
|
linux_admin_user=env_cfg.user or None,
|
||||||
|
linux_admin_password=env_cfg.password or None,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
|
||||||
|
if user is not None and user.strip():
|
||||||
|
row.linux_admin_user = user.strip()
|
||||||
|
if password is not None and password.strip():
|
||||||
|
row.linux_admin_password = password
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return get_effective_linux_admin_config(db)
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""SSH connectivity and remote commands for Linux hosts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.models import Host
|
||||||
|
|
||||||
|
SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
|
||||||
|
SSH_OUTPUT_MAX_LEN = 12_000
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxAdminNotConfiguredError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HostNotLinuxError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HostTargetMissingError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SshCommandResult:
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
target: str
|
||||||
|
stdout: str = ""
|
||||||
|
stderr: str = ""
|
||||||
|
exit_code: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_linux_host(host: Host) -> bool:
|
||||||
|
if (host.os_family or "").strip().lower() == "linux":
|
||||||
|
return True
|
||||||
|
return (host.product or "").strip() == "ssh-monitor"
|
||||||
|
|
||||||
|
|
||||||
|
def iter_ssh_targets(host: Host) -> list[str]:
|
||||||
|
if not is_linux_host(host):
|
||||||
|
raise HostNotLinuxError("Host is not Linux")
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
ordered: list[str] = []
|
||||||
|
|
||||||
|
def add(value: str | None) -> None:
|
||||||
|
text = (value or "").strip()
|
||||||
|
if not text or text.casefold() in seen:
|
||||||
|
return
|
||||||
|
seen.add(text.casefold())
|
||||||
|
ordered.append(text)
|
||||||
|
|
||||||
|
add(host.hostname)
|
||||||
|
add(host.display_name)
|
||||||
|
add(host.ipv4)
|
||||||
|
if not ordered:
|
||||||
|
raise HostTargetMissingError("Host has no hostname or IPv4 for SSH")
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_output(text: str) -> str:
|
||||||
|
if len(text) <= SSH_OUTPUT_MAX_LEN:
|
||||||
|
return text
|
||||||
|
return text[:SSH_OUTPUT_MAX_LEN] + "\n… (truncated)"
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_shell_command(user: str, remote_cmd: str) -> str:
|
||||||
|
safe_cmd = remote_cmd.replace("'", "'\"'\"'")
|
||||||
|
if user == "root":
|
||||||
|
return f"bash -lc '{safe_cmd}'"
|
||||||
|
return f"bash -lc 'sudo -n {safe_cmd} 2>/dev/null || sudo {safe_cmd}'"
|
||||||
|
|
||||||
|
|
||||||
|
def run_ssh_command(
|
||||||
|
*,
|
||||||
|
target: str,
|
||||||
|
user: str,
|
||||||
|
password: str,
|
||||||
|
remote_cmd: str,
|
||||||
|
connect_timeout_sec: int = 15,
|
||||||
|
command_timeout_sec: int = 600,
|
||||||
|
) -> SshCommandResult:
|
||||||
|
target = target.strip()
|
||||||
|
user = user.strip()
|
||||||
|
if not target or not user or not password:
|
||||||
|
raise LinuxAdminNotConfiguredError("Linux SSH admin credentials or target missing")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import paramiko
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("paramiko is not installed on SAC server") from exc
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
shell_cmd = _remote_shell_command(user, remote_cmd)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
hostname=target,
|
||||||
|
username=user,
|
||||||
|
password=password,
|
||||||
|
timeout=connect_timeout_sec,
|
||||||
|
allow_agent=False,
|
||||||
|
look_for_keys=False,
|
||||||
|
)
|
||||||
|
_stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec)
|
||||||
|
exit_code = stdout.channel.recv_exit_status()
|
||||||
|
out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace"))
|
||||||
|
err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace"))
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
return SshCommandResult(
|
||||||
|
ok=False,
|
||||||
|
message=f"SSH auth failed for {user}@{target}",
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
except (socket.timeout, TimeoutError):
|
||||||
|
return SshCommandResult(
|
||||||
|
ok=False,
|
||||||
|
message=f"SSH timeout ({connect_timeout_sec}s connect / {command_timeout_sec}s cmd) to {target}",
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return SshCommandResult(
|
||||||
|
ok=False,
|
||||||
|
message=f"SSH error ({target}): {exc}",
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
ok = exit_code == 0
|
||||||
|
if ok:
|
||||||
|
message = f"SSH OK ({target}), exit 0"
|
||||||
|
if out_text.strip():
|
||||||
|
message = f"{message}\n{out_text.strip().splitlines()[0][:200]}"
|
||||||
|
else:
|
||||||
|
detail = err_text.strip() or out_text.strip() or f"exit code {exit_code}"
|
||||||
|
message = f"SSH command failed ({target}): {detail[:500]}"
|
||||||
|
|
||||||
|
return SshCommandResult(
|
||||||
|
ok=ok,
|
||||||
|
message=message,
|
||||||
|
target=target,
|
||||||
|
stdout=out_text,
|
||||||
|
stderr=err_text,
|
||||||
|
exit_code=exit_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssh_connection(
|
||||||
|
*,
|
||||||
|
target: str,
|
||||||
|
user: str,
|
||||||
|
password: str,
|
||||||
|
timeout_sec: int = 15,
|
||||||
|
) -> SshCommandResult:
|
||||||
|
result = run_ssh_command(
|
||||||
|
target=target,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
remote_cmd="hostname",
|
||||||
|
connect_timeout_sec=timeout_sec,
|
||||||
|
command_timeout_sec=timeout_sec,
|
||||||
|
)
|
||||||
|
if result.ok and result.stdout.strip():
|
||||||
|
host = result.stdout.strip().splitlines()[0]
|
||||||
|
return SshCommandResult(
|
||||||
|
ok=True,
|
||||||
|
message=f"SSH OK, hostname={host}",
|
||||||
|
target=target,
|
||||||
|
stdout=result.stdout,
|
||||||
|
stderr=result.stderr,
|
||||||
|
exit_code=result.exit_code,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def run_ssh_monitor_update(
|
||||||
|
*,
|
||||||
|
target: str,
|
||||||
|
user: str,
|
||||||
|
password: str,
|
||||||
|
) -> SshCommandResult:
|
||||||
|
script = SSH_MONITOR_UPDATE_SCRIPT
|
||||||
|
check_cmd = f"test -x {script} && echo ready || echo missing"
|
||||||
|
probe = run_ssh_command(
|
||||||
|
target=target,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
remote_cmd=check_cmd,
|
||||||
|
command_timeout_sec=30,
|
||||||
|
)
|
||||||
|
if not probe.ok or "missing" in probe.stdout:
|
||||||
|
return SshCommandResult(
|
||||||
|
ok=False,
|
||||||
|
message=f"Update script not found or not executable: {script} on {target}",
|
||||||
|
target=target,
|
||||||
|
stdout=probe.stdout,
|
||||||
|
stderr=probe.stderr,
|
||||||
|
exit_code=probe.exit_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return run_ssh_command(
|
||||||
|
target=target,
|
||||||
|
user=user,
|
||||||
|
password=password,
|
||||||
|
remote_cmd=script,
|
||||||
|
command_timeout_sec=900,
|
||||||
|
)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.10.4"
|
APP_VERSION = "0.11.0"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ google-auth>=2.36.0,<3
|
|||||||
requests>=2.32.0,<3
|
requests>=2.32.0,<3
|
||||||
psutil>=6.1.0,<8
|
psutil>=6.1.0,<8
|
||||||
pywinrm>=0.5.0,<1
|
pywinrm>=0.5.0,<1
|
||||||
|
paramiko>=3.5.0,<4
|
||||||
|
|
||||||
# dev
|
# dev
|
||||||
pytest>=8.3.0,<9
|
pytest>=8.3.0,<9
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
|||||||
|
|
||||||
|
|
||||||
def test_version_constants():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.10.4"
|
assert APP_VERSION == "0.11.0"
|
||||||
assert APP_NAME == "Security Alert Center"
|
assert APP_NAME == "Security Alert Center"
|
||||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.10.4"
|
assert APP_VERSION_LABEL == "Security Alert Center v.0.11.0"
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Linux admin settings and SSH host actions."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.models import Host
|
||||||
|
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||||
|
from app.services.ssh_connect import SshCommandResult, iter_ssh_targets
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_linux_admin_settings_persists(jwt_headers, client, db_session, monkeypatch):
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "")
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "")
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/settings/linux-admin",
|
||||||
|
headers=jwt_headers,
|
||||||
|
json={"user": "root", "password": "secret-pass"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["configured"] is True
|
||||||
|
assert body["user"] == "root"
|
||||||
|
assert body["source"] == "db"
|
||||||
|
|
||||||
|
row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
|
assert row is not None
|
||||||
|
assert row.linux_admin_user == "root"
|
||||||
|
assert row.linux_admin_password == "secret-pass"
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_ssh_targets_hostname_before_ip():
|
||||||
|
host = Host(
|
||||||
|
hostname="ubabuba",
|
||||||
|
os_family="linux",
|
||||||
|
product="ssh-monitor",
|
||||||
|
ipv4="10.0.0.5",
|
||||||
|
)
|
||||||
|
targets = iter_ssh_targets(host)
|
||||||
|
assert targets[0] == "ubabuba"
|
||||||
|
assert targets[-1] == "10.0.0.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_agent_update_via_ssh(jwt_headers, client, db_session, monkeypatch):
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
host = Host(
|
||||||
|
hostname="ubabuba",
|
||||||
|
os_family="linux",
|
||||||
|
product="ssh-monitor",
|
||||||
|
ipv4="10.0.0.5",
|
||||||
|
)
|
||||||
|
db_session.add(host)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(host)
|
||||||
|
|
||||||
|
with patch("app.api.v1.hosts.run_ssh_monitor_update") as mock_update:
|
||||||
|
mock_update.return_value = SshCommandResult(
|
||||||
|
ok=True,
|
||||||
|
message="SSH OK",
|
||||||
|
target="ubabuba",
|
||||||
|
stdout="updated",
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/hosts/{host.id}/actions/agent-update",
|
||||||
|
headers=jwt_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["target"] == "ubabuba"
|
||||||
@@ -88,6 +88,10 @@ SAC_RDG_FLAP_DEDUP_SEC=30
|
|||||||
# SAC_WIN_ADMIN_USER=B26\\Administrator
|
# SAC_WIN_ADMIN_USER=B26\\Administrator
|
||||||
# SAC_WIN_ADMIN_PASSWORD=
|
# SAC_WIN_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# SSH / update_ssh_monitor.sh на Linux-хостах
|
||||||
|
# SAC_LINUX_ADMIN_USER=root
|
||||||
|
# SAC_LINUX_ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Retention (systemd sac-retention.timer)
|
# Retention (systemd sac-retention.timer)
|
||||||
SAC_EVENTS_RETENTION_DAYS=90
|
SAC_EVENTS_RETENTION_DAYS=90
|
||||||
SAC_PROBLEMS_RETENTION_DAYS=180
|
SAC_PROBLEMS_RETENTION_DAYS=180
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sac-ui",
|
"name": "sac-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.4",
|
"version": "0.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -446,6 +446,48 @@ export function testHostWinRm(hostId: number): Promise<HostWinRmTestResult> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinuxAdminSettings {
|
||||||
|
configured: boolean;
|
||||||
|
user: string | null;
|
||||||
|
password_hint: string | null;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchLinuxAdminSettings(): Promise<LinuxAdminSettings> {
|
||||||
|
return apiFetch<LinuxAdminSettings>("/api/v1/settings/linux-admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLinuxAdminSettings(payload: {
|
||||||
|
user?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
}): Promise<LinuxAdminSettings> {
|
||||||
|
return apiFetch<LinuxAdminSettings>("/api/v1/settings/linux-admin", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostSshActionResult {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
target: string;
|
||||||
|
stdout: string | null;
|
||||||
|
stderr: string | null;
|
||||||
|
exit_code: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testHostSsh(hostId: number): Promise<HostSshActionResult> {
|
||||||
|
return apiFetch<HostSshActionResult>(`/api/v1/hosts/${hostId}/actions/ssh-test`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateHostAgentViaSsh(hostId: number): Promise<HostSshActionResult> {
|
||||||
|
return apiFetch<HostSshActionResult>(`/api/v1/hosts/${hostId}/actions/agent-update`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
events_last_24h: number;
|
events_last_24h: number;
|
||||||
hosts_total: number;
|
hosts_total: number;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
export const APP_NAME = "Security Alert Center";
|
||||||
export const APP_VERSION = "0.10.4";
|
export const APP_VERSION = "0.11.0";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -58,6 +58,26 @@
|
|||||||
</button>
|
</button>
|
||||||
<p v-if="winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p>
|
<p v-if="winRmTestMessage" :class="winRmTestOk ? 'success' : 'error'">{{ winRmTestMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isLinuxHost" class="host-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="testingSsh"
|
||||||
|
@click="runSshTest"
|
||||||
|
>
|
||||||
|
{{ testingSsh ? "Проверка…" : "Проверить SSH" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="updatingAgent"
|
||||||
|
@click="runAgentUpdate"
|
||||||
|
>
|
||||||
|
{{ updatingAgent ? "Обновление…" : "Обновить ssh-monitor" }}
|
||||||
|
</button>
|
||||||
|
<p v-if="sshActionMessage" :class="sshActionOk ? 'success' : 'error'">{{ sshActionMessage }}</p>
|
||||||
|
<pre v-if="sshActionOutput" class="host-ssh-output">{{ sshActionOutput }}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="inventory" class="host-inventory card">
|
<section v-if="inventory" class="host-inventory card">
|
||||||
@@ -177,6 +197,8 @@ import {
|
|||||||
fetchHost,
|
fetchHost,
|
||||||
fetchRecentEvents,
|
fetchRecentEvents,
|
||||||
testHostWinRm,
|
testHostWinRm,
|
||||||
|
testHostSsh,
|
||||||
|
updateHostAgentViaSsh,
|
||||||
type EventListResponse,
|
type EventListResponse,
|
||||||
type HostDetail,
|
type HostDetail,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
@@ -195,6 +217,11 @@ const eventsPageSize = 50;
|
|||||||
const testingWinRm = ref(false);
|
const testingWinRm = ref(false);
|
||||||
const winRmTestMessage = ref("");
|
const winRmTestMessage = ref("");
|
||||||
const winRmTestOk = ref(false);
|
const winRmTestOk = ref(false);
|
||||||
|
const testingSsh = ref(false);
|
||||||
|
const updatingAgent = ref(false);
|
||||||
|
const sshActionMessage = ref("");
|
||||||
|
const sshActionOk = ref(false);
|
||||||
|
const sshActionOutput = ref("");
|
||||||
let refreshingLive = false;
|
let refreshingLive = false;
|
||||||
|
|
||||||
useSacLiveEventStream(() => {
|
useSacLiveEventStream(() => {
|
||||||
@@ -210,6 +237,13 @@ const isWindowsHost = computed(() => {
|
|||||||
return h.product === "rdp-login-monitor";
|
return h.product === "rdp-login-monitor";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isLinuxHost = computed(() => {
|
||||||
|
const h = host.value;
|
||||||
|
if (!h) return false;
|
||||||
|
if ((h.os_family || "").toLowerCase() === "linux") return true;
|
||||||
|
return h.product === "ssh-monitor";
|
||||||
|
});
|
||||||
|
|
||||||
const inventory = computed(() => host.value?.inventory ?? null);
|
const inventory = computed(() => host.value?.inventory ?? null);
|
||||||
|
|
||||||
const windowsFields = computed(() => {
|
const windowsFields = computed(() => {
|
||||||
@@ -289,6 +323,8 @@ async function loadHost() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
winRmTestMessage.value = "";
|
winRmTestMessage.value = "";
|
||||||
|
sshActionMessage.value = "";
|
||||||
|
sshActionOutput.value = "";
|
||||||
try {
|
try {
|
||||||
host.value = await fetchHost(hostId.value);
|
host.value = await fetchHost(hostId.value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -313,6 +349,57 @@ async function runWinRmTest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSshOutput(result: { stdout: string | null; stderr: string | null; exit_code: number | null }) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (result.stdout?.trim()) {
|
||||||
|
parts.push(result.stdout.trim());
|
||||||
|
}
|
||||||
|
if (result.stderr?.trim()) {
|
||||||
|
parts.push(result.stderr.trim());
|
||||||
|
}
|
||||||
|
if (result.exit_code != null) {
|
||||||
|
parts.push(`exit code: ${result.exit_code}`);
|
||||||
|
}
|
||||||
|
return parts.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSshTest() {
|
||||||
|
testingSsh.value = true;
|
||||||
|
sshActionMessage.value = "";
|
||||||
|
sshActionOutput.value = "";
|
||||||
|
try {
|
||||||
|
const result = await testHostSsh(hostId.value);
|
||||||
|
sshActionOk.value = result.ok;
|
||||||
|
sshActionMessage.value = result.message;
|
||||||
|
sshActionOutput.value = formatSshOutput(result);
|
||||||
|
} catch (e) {
|
||||||
|
sshActionOk.value = false;
|
||||||
|
sshActionMessage.value = e instanceof Error ? e.message : "Ошибка проверки SSH";
|
||||||
|
} finally {
|
||||||
|
testingSsh.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAgentUpdate() {
|
||||||
|
updatingAgent.value = true;
|
||||||
|
sshActionMessage.value = "";
|
||||||
|
sshActionOutput.value = "";
|
||||||
|
try {
|
||||||
|
const result = await updateHostAgentViaSsh(hostId.value);
|
||||||
|
sshActionOk.value = result.ok;
|
||||||
|
sshActionMessage.value = result.message;
|
||||||
|
sshActionOutput.value = formatSshOutput(result);
|
||||||
|
if (result.ok) {
|
||||||
|
host.value = await fetchHost(hostId.value);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
sshActionOk.value = false;
|
||||||
|
sshActionMessage.value = e instanceof Error ? e.message : "Ошибка обновления агента";
|
||||||
|
} finally {
|
||||||
|
updatingAgent.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshFromLatestEvent() {
|
async function refreshFromLatestEvent() {
|
||||||
if (refreshingLive) return;
|
if (refreshingLive) return;
|
||||||
refreshingLive = true;
|
refreshingLive = true;
|
||||||
@@ -380,4 +467,23 @@ watch(
|
|||||||
.muted {
|
.muted {
|
||||||
color: #9aa4b2;
|
color: #9aa4b2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.host-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-ssh-output {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #0d1117;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -82,6 +82,44 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card settings-card settings-policy">
|
||||||
|
<h2>Linux (SSH / обновление ssh-monitor)</h2>
|
||||||
|
<p class="settings-hint settings-hint-top">
|
||||||
|
SSH-учётка для удалённого запуска <code>/opt/scripts/update_ssh_monitor.sh</code> с карточки Linux-хоста.
|
||||||
|
Рекомендуется <code>root</code> или пользователь с <code>sudo NOPASSWD</code>.
|
||||||
|
Пока запись не создана в UI — используются <code>SAC_LINUX_ADMIN_*</code> из <code>sac-api.env</code>.
|
||||||
|
</p>
|
||||||
|
<form class="settings-form" @submit.prevent="saveLinuxAdminSettings">
|
||||||
|
<label class="settings-field">
|
||||||
|
SSH login
|
||||||
|
<input
|
||||||
|
v-model="linuxAdminForm.user"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="root"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
SSH password
|
||||||
|
<input
|
||||||
|
v-model="linuxAdminForm.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="linuxAdminPasswordPlaceholder"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p v-if="linuxAdminLoaded" class="settings-meta">
|
||||||
|
Настроен: {{ linuxAdminLoaded.configured ? "да" : "нет" }}
|
||||||
|
· источник: <code>{{ linuxAdminLoaded.source }}</code>
|
||||||
|
</p>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button type="submit" :disabled="savingLinuxAdmin">
|
||||||
|
{{ savingLinuxAdmin ? "Сохранение…" : "Сохранить Linux admin" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card settings-card settings-policy">
|
<section class="card settings-card settings-policy">
|
||||||
<h2>Правило оповещений</h2>
|
<h2>Правило оповещений</h2>
|
||||||
<p class="settings-hint settings-hint-top">
|
<p class="settings-hint settings-hint-top">
|
||||||
@@ -475,12 +513,14 @@ import {
|
|||||||
fetchMobileSettings,
|
fetchMobileSettings,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
fetchWinAdminSettings,
|
fetchWinAdminSettings,
|
||||||
|
fetchLinuxAdminSettings,
|
||||||
revokeMobileDevice,
|
revokeMobileDevice,
|
||||||
deleteMobileDevice,
|
deleteMobileDevice,
|
||||||
revokeMobileEnrollmentCode,
|
revokeMobileEnrollmentCode,
|
||||||
testMobileDevicePush,
|
testMobileDevicePush,
|
||||||
updateMobileSettings,
|
updateMobileSettings,
|
||||||
updateWinAdminSettings,
|
updateWinAdminSettings,
|
||||||
|
updateLinuxAdminSettings,
|
||||||
type MobileDevice,
|
type MobileDevice,
|
||||||
type MobileEnrollmentCode,
|
type MobileEnrollmentCode,
|
||||||
type MobileSettings,
|
type MobileSettings,
|
||||||
@@ -542,6 +582,13 @@ interface WinAdminSettings {
|
|||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LinuxAdminSettings {
|
||||||
|
configured: boolean;
|
||||||
|
user: string | null;
|
||||||
|
password_hint: string | null;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SeverityOverrideRowApi {
|
interface SeverityOverrideRowApi {
|
||||||
event_type: string;
|
event_type: string;
|
||||||
default_severity: string;
|
default_severity: string;
|
||||||
@@ -556,6 +603,7 @@ const loading = ref(true);
|
|||||||
const savingPolicy = ref(false);
|
const savingPolicy = ref(false);
|
||||||
const savingUi = ref(false);
|
const savingUi = ref(false);
|
||||||
const savingWinAdmin = ref(false);
|
const savingWinAdmin = ref(false);
|
||||||
|
const savingLinuxAdmin = ref(false);
|
||||||
const savingTelegram = ref(false);
|
const savingTelegram = ref(false);
|
||||||
const savingWebhook = ref(false);
|
const savingWebhook = ref(false);
|
||||||
const savingEmail = ref(false);
|
const savingEmail = ref(false);
|
||||||
@@ -574,6 +622,7 @@ const emailLoaded = ref<EmailSettings | null>(null);
|
|||||||
const policyLoaded = ref<NotificationPolicy | null>(null);
|
const policyLoaded = ref<NotificationPolicy | null>(null);
|
||||||
const uiLoaded = ref<UiSettings | null>(null);
|
const uiLoaded = ref<UiSettings | null>(null);
|
||||||
const winAdminLoaded = ref<WinAdminSettings | null>(null);
|
const winAdminLoaded = ref<WinAdminSettings | null>(null);
|
||||||
|
const linuxAdminLoaded = ref<LinuxAdminSettings | null>(null);
|
||||||
const severityRows = ref<SeverityOverrideRowUi[]>([]);
|
const severityRows = ref<SeverityOverrideRowUi[]>([]);
|
||||||
const severityFilter = ref("");
|
const severityFilter = ref("");
|
||||||
const mobileLoaded = ref<MobileSettings | null>(null);
|
const mobileLoaded = ref<MobileSettings | null>(null);
|
||||||
@@ -641,6 +690,11 @@ const winAdminForm = reactive({
|
|||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const linuxAdminForm = reactive({
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
const telegramForm = reactive({
|
const telegramForm = reactive({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
bot_token: "",
|
bot_token: "",
|
||||||
@@ -696,6 +750,11 @@ const winAdminPasswordPlaceholder = computed(() =>
|
|||||||
? `оставить ${winAdminLoaded.value.password_hint}`
|
? `оставить ${winAdminLoaded.value.password_hint}`
|
||||||
: "обязателен при первой настройке",
|
: "обязателен при первой настройке",
|
||||||
);
|
);
|
||||||
|
const linuxAdminPasswordPlaceholder = computed(() =>
|
||||||
|
linuxAdminLoaded.value?.password_hint
|
||||||
|
? `оставить ${linuxAdminLoaded.value.password_hint}`
|
||||||
|
: "обязателен при первой настройке",
|
||||||
|
);
|
||||||
|
|
||||||
const filteredSeverityRows = computed(() => {
|
const filteredSeverityRows = computed(() => {
|
||||||
const q = severityFilter.value.trim().toLowerCase();
|
const q = severityFilter.value.trim().toLowerCase();
|
||||||
@@ -812,6 +871,44 @@ async function saveWinAdminSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadLinuxAdminSettings() {
|
||||||
|
const data = await fetchLinuxAdminSettings();
|
||||||
|
linuxAdminLoaded.value = data;
|
||||||
|
linuxAdminForm.user = data.user ?? "";
|
||||||
|
linuxAdminForm.password = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLinuxAdminSettings() {
|
||||||
|
savingLinuxAdmin.value = true;
|
||||||
|
error.value = "";
|
||||||
|
success.value = "";
|
||||||
|
try {
|
||||||
|
const payload: { user?: string; password?: string } = {};
|
||||||
|
if (linuxAdminForm.user.trim()) {
|
||||||
|
payload.user = linuxAdminForm.user.trim();
|
||||||
|
}
|
||||||
|
if (linuxAdminForm.password.trim()) {
|
||||||
|
payload.password = linuxAdminForm.password;
|
||||||
|
}
|
||||||
|
if (!payload.user && !payload.password) {
|
||||||
|
error.value = "Укажите логин и/или новый пароль Linux admin";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!linuxAdminLoaded.value?.configured && (!payload.user || !payload.password)) {
|
||||||
|
error.value = "Для первой настройки укажите логин и пароль";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
linuxAdminLoaded.value = await updateLinuxAdminSettings(payload);
|
||||||
|
linuxAdminForm.user = linuxAdminLoaded.value.user ?? linuxAdminForm.user;
|
||||||
|
linuxAdminForm.password = "";
|
||||||
|
success.value = "Учётные данные Linux admin сохранены.";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка сохранения Linux admin";
|
||||||
|
} finally {
|
||||||
|
savingLinuxAdmin.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMobileSection() {
|
async function loadMobileSection() {
|
||||||
mobileLoaded.value = await fetchMobileSettings();
|
mobileLoaded.value = await fetchMobileSettings();
|
||||||
mobileForm.devices_allowed = mobileLoaded.value.devices_allowed;
|
mobileForm.devices_allowed = mobileLoaded.value.devices_allowed;
|
||||||
@@ -958,6 +1055,7 @@ async function load() {
|
|||||||
emailForm.smtp_ssl = data.email.smtp_ssl;
|
emailForm.smtp_ssl = data.email.smtp_ssl;
|
||||||
await loadUiSettings();
|
await loadUiSettings();
|
||||||
await loadWinAdminSettings();
|
await loadWinAdminSettings();
|
||||||
|
await loadLinuxAdminSettings();
|
||||||
await loadSeverityOverrides();
|
await loadSeverityOverrides();
|
||||||
await loadMobileSection();
|
await loadMobileSection();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user