feat: per-host WinRM/SSH credentials override (0.5.17)
Allow host-level login/password for home/workgroup PCs while keeping global domain admins in Settings.
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
"""per-host management credentials (SSH / WinRM override)
|
||||||
|
|
||||||
|
Revision ID: 027_host_mgmt_credentials
|
||||||
|
Revises: 026_host_silence_dedupe
|
||||||
|
Create Date: 2026-07-14
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "027_host_mgmt_credentials"
|
||||||
|
down_revision = "026_host_silence_dedupe"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("hosts", sa.Column("mgmt_user", sa.String(length=256), nullable=True))
|
||||||
|
op.add_column("hosts", sa.Column("mgmt_password", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("hosts", "mgmt_password")
|
||||||
|
op.drop_column("hosts", "mgmt_user")
|
||||||
@@ -28,12 +28,12 @@ from app.services.host_sessions import (
|
|||||||
mark_event_session_terminated,
|
mark_event_session_terminated,
|
||||||
terminate_session_for_event,
|
terminate_session_for_event,
|
||||||
)
|
)
|
||||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||||
from app.services.ssh_connect import (
|
from app.services.ssh_connect import (
|
||||||
HostNotLinuxError as SshHostNotLinuxError,
|
HostNotLinuxError as SshHostNotLinuxError,
|
||||||
HostTargetMissingError as SshHostTargetMissingError,
|
HostTargetMissingError as SshHostTargetMissingError,
|
||||||
)
|
)
|
||||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||||
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
|
from app.services.winrm_connect import HostNotWindowsError, HostTargetMissingError
|
||||||
from app.services.ingest import ingest_event
|
from app.services.ingest import ingest_event
|
||||||
from app.services.event_summary import event_to_summary
|
from app.services.event_summary import event_to_summary
|
||||||
@@ -284,8 +284,10 @@ def post_event_terminate_session(
|
|||||||
if event_session_terminated(event, db=db):
|
if event_session_terminated(event, db=db):
|
||||||
raise HTTPException(status_code=409, detail="Session already terminated for this event")
|
raise HTTPException(status_code=409, detail="Session already terminated for this event")
|
||||||
|
|
||||||
linux_cfg = get_effective_linux_admin_config(db)
|
if event.host is None:
|
||||||
win_cfg = get_effective_win_admin_config(db)
|
raise HTTPException(status_code=400, detail="Event has no host")
|
||||||
|
linux_cfg = get_effective_linux_admin_for_host(db, event.host)
|
||||||
|
win_cfg = get_effective_win_admin_for_host(db, event.host)
|
||||||
sid = (body.session_id if body else None) or event_session_id(event)
|
sid = (body.session_id if body else None) or event_session_id(event)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+109
-16
@@ -46,8 +46,12 @@ from app.services.host_sessions import (
|
|||||||
terminate_windows_session,
|
terminate_windows_session,
|
||||||
)
|
)
|
||||||
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.host_mgmt_credentials import (
|
||||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
get_host_mgmt_access_view,
|
||||||
|
upsert_host_mgmt_credentials,
|
||||||
|
)
|
||||||
|
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||||
|
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||||
from app.services.winrm_connect import (
|
from app.services.winrm_connect import (
|
||||||
HostNotWindowsError,
|
HostNotWindowsError,
|
||||||
HostTargetMissingError,
|
HostTargetMissingError,
|
||||||
@@ -299,6 +303,23 @@ class HostAgentConfigResponse(BaseModel):
|
|||||||
settings: dict[str, object]
|
settings: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
class HostMgmtAccessResponse(BaseModel):
|
||||||
|
host_id: int
|
||||||
|
has_override: bool
|
||||||
|
user: str | None = None
|
||||||
|
password_set: bool = False
|
||||||
|
password_hint: str | None = None
|
||||||
|
effective_source: str
|
||||||
|
effective_configured: bool
|
||||||
|
effective_user: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HostMgmtAccessUpdate(BaseModel):
|
||||||
|
user: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
clear: bool = False
|
||||||
|
|
||||||
|
|
||||||
class HostRemoteActionStartResponse(BaseModel):
|
class HostRemoteActionStartResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
host_id: int
|
host_id: int
|
||||||
@@ -469,9 +490,12 @@ def test_host_winrm(
|
|||||||
if host is None:
|
if host is None:
|
||||||
raise HTTPException(status_code=404, detail="Host not found")
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
cfg = get_effective_win_admin_config(db)
|
cfg = get_effective_win_admin_for_host(db, host)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
targets = iter_winrm_targets(host)
|
targets = iter_winrm_targets(host)
|
||||||
@@ -532,9 +556,12 @@ def test_host_ssh(
|
|||||||
if host is None:
|
if host is None:
|
||||||
raise HTTPException(status_code=404, detail="Host not found")
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
cfg = get_effective_linux_admin_config(db)
|
cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||||
|
)
|
||||||
|
|
||||||
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
|
response = _run_ssh_action_on_host(host, cfg, action=test_ssh_connection)
|
||||||
_set_ssh_admin_status(host, response.ok)
|
_set_ssh_admin_status(host, response.ok)
|
||||||
@@ -556,9 +583,12 @@ def update_host_agent_via_ssh(
|
|||||||
if host is None:
|
if host is None:
|
||||||
raise HTTPException(status_code=404, detail="Host not found")
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
cfg = get_effective_linux_admin_config(db)
|
cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||||
|
)
|
||||||
|
|
||||||
title = "Обновление ssh-monitor (SSH)"
|
title = "Обновление ssh-monitor (SSH)"
|
||||||
try:
|
try:
|
||||||
@@ -686,9 +716,12 @@ def list_host_sessions(
|
|||||||
from app.services.winrm_connect import is_windows_host
|
from app.services.winrm_connect import is_windows_host
|
||||||
|
|
||||||
if is_linux_host(host):
|
if is_linux_host(host):
|
||||||
cfg = get_effective_linux_admin_config(db)
|
cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
sessions, result = list_linux_sessions(host, cfg)
|
sessions, result = list_linux_sessions(host, cfg)
|
||||||
except SshHostNotLinuxError as exc:
|
except SshHostNotLinuxError as exc:
|
||||||
@@ -703,9 +736,12 @@ def list_host_sessions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if is_windows_host(host):
|
if is_windows_host(host):
|
||||||
cfg = get_effective_win_admin_config(db)
|
cfg = get_effective_win_admin_for_host(db, host)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
sessions, result = list_windows_sessions(host, cfg)
|
sessions, result = list_windows_sessions(host, cfg)
|
||||||
except HostNotWindowsError as exc:
|
except HostNotWindowsError as exc:
|
||||||
@@ -743,9 +779,12 @@ def terminate_host_session(
|
|||||||
raise HTTPException(status_code=422, detail="session_id is required")
|
raise HTTPException(status_code=422, detail="session_id is required")
|
||||||
|
|
||||||
if is_linux_host(host):
|
if is_linux_host(host):
|
||||||
cfg = get_effective_linux_admin_config(db)
|
cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(status_code=400, detail="Linux SSH admin is not configured")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Linux SSH admin is not configured (host override or Settings → Linux)",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
result = terminate_linux_session(host, cfg, sid)
|
result = terminate_linux_session(host, cfg, sid)
|
||||||
except SshHostNotLinuxError as exc:
|
except SshHostNotLinuxError as exc:
|
||||||
@@ -761,9 +800,12 @@ def terminate_host_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if is_windows_host(host):
|
if is_windows_host(host):
|
||||||
cfg = get_effective_win_admin_config(db)
|
cfg = get_effective_win_admin_for_host(db, host)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(status_code=400, detail="Windows domain admin is not configured")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
result = terminate_windows_session(host, cfg, sid)
|
result = terminate_windows_session(host, cfg, sid)
|
||||||
except HostNotWindowsError as exc:
|
except HostNotWindowsError as exc:
|
||||||
@@ -803,6 +845,57 @@ def patch_host_agent_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{host_id}/access", response_model=HostMgmtAccessResponse)
|
||||||
|
def get_host_access(
|
||||||
|
host_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostMgmtAccessResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
view = get_host_mgmt_access_view(db, host)
|
||||||
|
return HostMgmtAccessResponse(
|
||||||
|
host_id=view.host_id,
|
||||||
|
has_override=view.has_override,
|
||||||
|
user=view.user,
|
||||||
|
password_set=view.password_set,
|
||||||
|
password_hint=view.password_hint,
|
||||||
|
effective_source=view.effective_source,
|
||||||
|
effective_configured=view.effective_configured,
|
||||||
|
effective_user=view.effective_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{host_id}/access", response_model=HostMgmtAccessResponse)
|
||||||
|
def put_host_access(
|
||||||
|
host_id: int,
|
||||||
|
body: HostMgmtAccessUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> HostMgmtAccessResponse:
|
||||||
|
host = db.get(Host, host_id)
|
||||||
|
if host is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
view = upsert_host_mgmt_credentials(
|
||||||
|
db,
|
||||||
|
host,
|
||||||
|
user=body.user,
|
||||||
|
password=body.password,
|
||||||
|
clear=body.clear,
|
||||||
|
)
|
||||||
|
return HostMgmtAccessResponse(
|
||||||
|
host_id=view.host_id,
|
||||||
|
has_override=view.has_override,
|
||||||
|
user=view.user,
|
||||||
|
password_set=view.password_set,
|
||||||
|
password_hint=view.password_hint,
|
||||||
|
effective_source=view.effective_source,
|
||||||
|
effective_configured=view.effective_configured,
|
||||||
|
effective_user=view.effective_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
@router.get("/{host_id}/agent-config", response_model=HostAgentConfigResponse)
|
||||||
def get_host_agent_config(
|
def get_host_agent_config(
|
||||||
host_id: int,
|
host_id: int,
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ class Host(Base):
|
|||||||
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
use_sac_mode: Mapped[str | None] = mapped_column(String(32))
|
||||||
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
ssh_admin_ok: Mapped[bool | None] = mapped_column(nullable=True)
|
||||||
ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
ssh_admin_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Optional override for SSH/WinRM (home PCs, unique local admin). Empty → global Settings.
|
||||||
|
mgmt_user: Mapped[str | None] = mapped_column(String(256))
|
||||||
|
mgmt_password: Mapped[str | None] = mapped_column(Text)
|
||||||
pending_agent_update: Mapped[bool] = mapped_column(default=False, server_default="false")
|
pending_agent_update: Mapped[bool] = mapped_column(default=False, server_default="false")
|
||||||
pending_update_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
pending_update_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
pending_update_target_version: Mapped[str | None] = mapped_column(String(64))
|
pending_update_target_version: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|||||||
@@ -7,17 +7,22 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.models import AgentCommand, Host
|
from app.models import AgentCommand, Host
|
||||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
from app.services.win_admin_settings import (
|
||||||
|
get_effective_win_admin_config,
|
||||||
|
get_effective_win_admin_for_host,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _win_admin_configured() -> bool:
|
def _win_admin_configured() -> bool:
|
||||||
return get_effective_win_admin_config().configured
|
return get_effective_win_admin_config().configured
|
||||||
|
|
||||||
|
|
||||||
def win_admin_run_as() -> dict[str, str] | None:
|
def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dict[str, str] | None:
|
||||||
cfg = get_effective_win_admin_config()
|
if host is not None and db is not None:
|
||||||
|
cfg = get_effective_win_admin_for_host(db, host)
|
||||||
|
else:
|
||||||
|
cfg = get_effective_win_admin_config(db)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
@@ -26,7 +31,13 @@ def win_admin_run_as() -> dict[str, str] | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict:
|
def command_to_dict(
|
||||||
|
cmd: AgentCommand,
|
||||||
|
*,
|
||||||
|
include_run_as: bool = False,
|
||||||
|
db: Session | None = None,
|
||||||
|
host: Host | None = None,
|
||||||
|
) -> dict:
|
||||||
out = {
|
out = {
|
||||||
"id": cmd.command_uuid,
|
"id": cmd.command_uuid,
|
||||||
"type": cmd.command_type,
|
"type": cmd.command_type,
|
||||||
@@ -38,7 +49,7 @@ def command_to_dict(cmd: AgentCommand, *, include_run_as: bool = False) -> dict:
|
|||||||
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
|
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
|
||||||
}
|
}
|
||||||
if include_run_as and cmd.status == "pending":
|
if include_run_as and cmd.status == "pending":
|
||||||
run_as = win_admin_run_as()
|
run_as = win_admin_run_as(db, host)
|
||||||
if run_as:
|
if run_as:
|
||||||
out["run_as"] = run_as
|
out["run_as"] = run_as
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -27,5 +27,5 @@ def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]:
|
|||||||
"config_revision": int(host.agent_config_revision or 0),
|
"config_revision": int(host.agent_config_revision or 0),
|
||||||
"config": config_payload,
|
"config": config_payload,
|
||||||
"update": update_block,
|
"update": update_block,
|
||||||
"commands": [command_to_dict(c, include_run_as=True) for c in pending],
|
"commands": [command_to_dict(c, include_run_as=True, db=db, host=host) for c in pending],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from app.services.agent_version import (
|
|||||||
host_version_outdated,
|
host_version_outdated,
|
||||||
latest_agent_versions_by_product,
|
latest_agent_versions_by_product,
|
||||||
)
|
)
|
||||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||||
from app.services.ssh_connect import (
|
from app.services.ssh_connect import (
|
||||||
HostNotLinuxError,
|
HostNotLinuxError,
|
||||||
HostTargetMissingError,
|
HostTargetMissingError,
|
||||||
@@ -28,7 +28,7 @@ from app.services.ssh_connect import (
|
|||||||
iter_ssh_targets,
|
iter_ssh_targets,
|
||||||
run_ssh_monitor_update,
|
run_ssh_monitor_update,
|
||||||
)
|
)
|
||||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||||
from app.services.winrm_connect import (
|
from app.services.winrm_connect import (
|
||||||
HostNotWindowsError,
|
HostNotWindowsError,
|
||||||
HostTargetMissingError as WinRmHostTargetMissingError,
|
HostTargetMissingError as WinRmHostTargetMissingError,
|
||||||
@@ -249,7 +249,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbac
|
|||||||
product = (host.product or "").strip()
|
product = (host.product or "").strip()
|
||||||
|
|
||||||
if product == PRODUCT_SSH or is_linux_host(host):
|
if product == PRODUCT_SSH or is_linux_host(host):
|
||||||
linux_cfg = get_effective_linux_admin_config(db)
|
linux_cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
agent_cfg = get_effective_agent_update_config(db)
|
agent_cfg = get_effective_agent_update_config(db)
|
||||||
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
||||||
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
||||||
@@ -260,7 +260,7 @@ def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbac
|
|||||||
git_branch=branch,
|
git_branch=branch,
|
||||||
)
|
)
|
||||||
elif product == PRODUCT_RDP or is_windows_host(host):
|
elif product == PRODUCT_RDP or is_windows_host(host):
|
||||||
win_cfg = get_effective_win_admin_config(db)
|
win_cfg = get_effective_win_admin_for_host(db, host)
|
||||||
result = run_windows_agent_update_fallback(
|
result = run_windows_agent_update_fallback(
|
||||||
host,
|
host,
|
||||||
win_cfg,
|
win_cfg,
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Per-host management credentials (override global Win/Linux admin)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.host import Host
|
||||||
|
from app.services.linux_admin_settings import (
|
||||||
|
LinuxAdminConfig,
|
||||||
|
get_effective_linux_admin_for_host,
|
||||||
|
)
|
||||||
|
from app.services.win_admin_settings import (
|
||||||
|
WinAdminConfig,
|
||||||
|
get_effective_win_admin_for_host,
|
||||||
|
normalize_win_admin_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mask_secret(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if len(text) <= 4:
|
||||||
|
return "****"
|
||||||
|
return f"{text[:2]}…{text[-2:]}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HostMgmtAccessView:
|
||||||
|
host_id: int
|
||||||
|
has_override: bool
|
||||||
|
user: str | None
|
||||||
|
password_set: bool
|
||||||
|
password_hint: str | None
|
||||||
|
effective_source: str
|
||||||
|
effective_configured: bool
|
||||||
|
effective_user: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_for_host(db: Session, host: Host) -> WinAdminConfig | LinuxAdminConfig:
|
||||||
|
os_family = (host.os_family or "").strip().lower()
|
||||||
|
if os_family == "windows" or (host.product or "").strip().lower() in {"rdp-login-monitor", "rdp"}:
|
||||||
|
return get_effective_win_admin_for_host(db, host)
|
||||||
|
if os_family == "linux" or (host.product or "").strip().lower() in {"ssh-monitor", "ssh"}:
|
||||||
|
return get_effective_linux_admin_for_host(db, host)
|
||||||
|
# Fallback: try host override first via win helper (same columns), then global win, then linux.
|
||||||
|
win_cfg = get_effective_win_admin_for_host(db, host)
|
||||||
|
if win_cfg.configured:
|
||||||
|
return win_cfg
|
||||||
|
return get_effective_linux_admin_for_host(db, host)
|
||||||
|
|
||||||
|
|
||||||
|
def get_host_mgmt_access_view(db: Session, host: Host) -> HostMgmtAccessView:
|
||||||
|
host_user = (host.mgmt_user or "").strip() or None
|
||||||
|
host_password = (host.mgmt_password or "").strip() or None
|
||||||
|
has_override = bool(host_user or host_password)
|
||||||
|
effective = _effective_for_host(db, host)
|
||||||
|
return HostMgmtAccessView(
|
||||||
|
host_id=host.id,
|
||||||
|
has_override=has_override,
|
||||||
|
user=host_user,
|
||||||
|
password_set=bool(host_password),
|
||||||
|
password_hint=mask_secret(host_password),
|
||||||
|
effective_source=effective.source,
|
||||||
|
effective_configured=effective.configured,
|
||||||
|
effective_user=effective.user or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_host_mgmt_credentials(
|
||||||
|
db: Session,
|
||||||
|
host: Host,
|
||||||
|
*,
|
||||||
|
user: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
clear: bool = False,
|
||||||
|
) -> HostMgmtAccessView:
|
||||||
|
"""Update per-host credentials.
|
||||||
|
|
||||||
|
- clear=True → wipe host override (use global Settings).
|
||||||
|
- user="" → clear username.
|
||||||
|
- password omitted (None) → keep existing password.
|
||||||
|
- password="" → clear password.
|
||||||
|
"""
|
||||||
|
if clear:
|
||||||
|
host.mgmt_user = None
|
||||||
|
host.mgmt_password = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(host)
|
||||||
|
return get_host_mgmt_access_view(db, host)
|
||||||
|
|
||||||
|
if user is not None:
|
||||||
|
cleaned = user.strip()
|
||||||
|
if not cleaned:
|
||||||
|
host.mgmt_user = None
|
||||||
|
else:
|
||||||
|
# Preserve DOMAIN\\user form for Windows; for Linux leave as-is after strip.
|
||||||
|
os_family = (host.os_family or "").strip().lower()
|
||||||
|
if os_family == "windows" or "\\" in cleaned or "@" in cleaned:
|
||||||
|
host.mgmt_user = normalize_win_admin_user(cleaned)
|
||||||
|
else:
|
||||||
|
host.mgmt_user = cleaned
|
||||||
|
|
||||||
|
if password is not None:
|
||||||
|
if not password.strip():
|
||||||
|
host.mgmt_password = None
|
||||||
|
else:
|
||||||
|
host.mgmt_password = password
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(host)
|
||||||
|
return get_host_mgmt_access_view(db, host)
|
||||||
@@ -14,7 +14,7 @@ from app.database import SessionLocal
|
|||||||
from app.models import Host
|
from app.models import Host
|
||||||
from app.services.agent_update import execute_agent_update_fallback
|
from app.services.agent_update import execute_agent_update_fallback
|
||||||
from app.services.agent_update_types import AgentUpdateFallbackResult
|
from app.services.agent_update_types import AgentUpdateFallbackResult
|
||||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||||
from app.services.agent_update_settings import get_effective_agent_update_config
|
from app.services.agent_update_settings import get_effective_agent_update_config
|
||||||
from app.services.ssh_connect import (
|
from app.services.ssh_connect import (
|
||||||
iter_ssh_targets,
|
iter_ssh_targets,
|
||||||
@@ -97,7 +97,7 @@ def _completion_message(result: SshCommandResult) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str:
|
def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str:
|
||||||
cfg = get_effective_linux_admin_config(db)
|
cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
@@ -191,7 +191,7 @@ def _poll_ssh_update_log_loop(
|
|||||||
payload = dict(row.remote_action or {})
|
payload = dict(row.remote_action or {})
|
||||||
if (payload.get("status") or "").strip().lower() != "running":
|
if (payload.get("status") or "").strip().lower() != "running":
|
||||||
continue
|
continue
|
||||||
cfg = get_effective_linux_admin_config(session)
|
cfg = get_effective_linux_admin_for_host(session, row)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -322,7 +322,7 @@ def start_host_remote_action(
|
|||||||
|
|
||||||
|
|
||||||
def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult:
|
def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult:
|
||||||
cfg = get_effective_linux_admin_config(db)
|
cfg = get_effective_linux_admin_for_host(db, host)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
return SshCommandResult(
|
return SshCommandResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Effective Linux SSH admin credentials (DB overrides env)."""
|
"""Effective Linux SSH admin credentials (host → DB → env)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.models.host import Host
|
||||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
|||||||
class LinuxAdminConfig:
|
class LinuxAdminConfig:
|
||||||
user: str
|
user: str
|
||||||
password: str
|
password: str
|
||||||
source: str # env | db
|
source: str # host | env | db
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
@@ -80,3 +81,16 @@ def upsert_linux_admin_settings(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return get_effective_linux_admin_config(db)
|
return get_effective_linux_admin_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_linux_admin_for_host(db: Session, host: Host) -> LinuxAdminConfig:
|
||||||
|
"""Prefer per-host mgmt_* when both user and password are set; else global Settings/env."""
|
||||||
|
host_user = (host.mgmt_user or "").strip()
|
||||||
|
host_password = (host.mgmt_password or "").strip()
|
||||||
|
if host_user and host_password:
|
||||||
|
return LinuxAdminConfig(user=host_user, password=host_password, source="host")
|
||||||
|
|
||||||
|
global_cfg = get_effective_linux_admin_config(db)
|
||||||
|
if host_user and global_cfg.password.strip():
|
||||||
|
return LinuxAdminConfig(user=host_user, password=global_cfg.password, source="host")
|
||||||
|
return global_cfg
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from app.models import AgentCommand, Event, Host
|
|||||||
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
|
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
|
||||||
from app.services.rdg_display import event_supports_rdg_client_qwinsta
|
from app.services.rdg_display import event_supports_rdg_client_qwinsta
|
||||||
from app.services.rdg_session_flap import event_internal_ip
|
from app.services.rdg_session_flap import event_internal_ip
|
||||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||||
from app.services.winrm_connect import (
|
from app.services.winrm_connect import (
|
||||||
WinRmCmdResult,
|
WinRmCmdResult,
|
||||||
run_winrm_logoff,
|
run_winrm_logoff,
|
||||||
@@ -21,12 +21,17 @@ from app.services.winrm_connect import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require_win_admin(db: Session):
|
def _require_win_admin(db: Session, host: Host | None = None):
|
||||||
|
if host is not None:
|
||||||
|
cfg = get_effective_win_admin_for_host(db, host)
|
||||||
|
else:
|
||||||
|
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||||
|
|
||||||
cfg = get_effective_win_admin_config(db)
|
cfg = get_effective_win_admin_config(db)
|
||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="Windows domain admin is not configured (Settings or SAC_WIN_ADMIN_*)",
|
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||||
)
|
)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
@@ -93,8 +98,8 @@ def _persist_command(
|
|||||||
|
|
||||||
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
||||||
_require_rdg_client_qwinsta(db, event)
|
_require_rdg_client_qwinsta(db, event)
|
||||||
cfg = _require_win_admin(db)
|
|
||||||
client_host = _resolve_client(db, event)
|
client_host = _resolve_client(db, event)
|
||||||
|
cfg = _require_win_admin(db, client_host)
|
||||||
|
|
||||||
details = event.details if isinstance(event.details, dict) else {}
|
details = event.details if isinstance(event.details, dict) else {}
|
||||||
user = details.get("user")
|
user = details.get("user")
|
||||||
@@ -136,8 +141,8 @@ def execute_logoff_via_winrm(
|
|||||||
requested_by: str,
|
requested_by: str,
|
||||||
) -> AgentCommand:
|
) -> AgentCommand:
|
||||||
_require_rdg_client_qwinsta(db, event)
|
_require_rdg_client_qwinsta(db, event)
|
||||||
cfg = _require_win_admin(db)
|
|
||||||
client_host = _resolve_client(db, event)
|
client_host = _resolve_client(db, event)
|
||||||
|
cfg = _require_win_admin(db, client_host)
|
||||||
|
|
||||||
details = event.details if isinstance(event.details, dict) else {}
|
details = event.details if isinstance(event.details, dict) else {}
|
||||||
user = details.get("user")
|
user = details.get("user")
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from app.services.rdg_workstation_session import (
|
|||||||
users_match_rdg,
|
users_match_rdg,
|
||||||
)
|
)
|
||||||
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings
|
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings
|
||||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||||
|
|
||||||
logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
|
logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ def _disconnect_on_workstation(
|
|||||||
user: str,
|
user: str,
|
||||||
login_event: Event | None,
|
login_event: Event | None,
|
||||||
) -> AutoDisconnectResult:
|
) -> AutoDisconnectResult:
|
||||||
win_cfg = get_effective_win_admin_config(db)
|
win_cfg = get_effective_win_admin_for_host(db, workstation)
|
||||||
sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
|
sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
|
||||||
if qwinsta is None or not qwinsta.ok:
|
if qwinsta is None or not qwinsta.ok:
|
||||||
message = qwinsta.message if qwinsta else "qwinsta failed"
|
message = qwinsta.message if qwinsta else "qwinsta failed"
|
||||||
@@ -291,14 +291,6 @@ def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDi
|
|||||||
if not get_effective_rdp_flap_settings(db).auto_disconnect:
|
if not get_effective_rdp_flap_settings(db).auto_disconnect:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
win_cfg = get_effective_win_admin_config(db)
|
|
||||||
if not win_cfg.configured:
|
|
||||||
logger.warning(
|
|
||||||
"auto rdp flap disconnect skipped: win admin not configured (event_id=%s)",
|
|
||||||
event.event_id,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
result = _auto_disconnect_rdg_flap(db, event)
|
result = _auto_disconnect_rdg_flap(db, event)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
if result.ok:
|
if result.ok:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Effective Windows domain admin credentials (DB overrides env)."""
|
"""Effective Windows domain admin credentials (host → DB → env)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.models.host import Host
|
||||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||||
|
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
|||||||
class WinAdminConfig:
|
class WinAdminConfig:
|
||||||
user: str
|
user: str
|
||||||
password: str
|
password: str
|
||||||
source: str # env | db
|
source: str # host | env | db
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
@@ -92,3 +93,17 @@ def upsert_win_admin_settings(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return get_effective_win_admin_config(db)
|
return get_effective_win_admin_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_win_admin_for_host(db: Session, host: Host) -> WinAdminConfig:
|
||||||
|
"""Prefer per-host mgmt_* when both user and password are set; else global Settings/env."""
|
||||||
|
host_user = normalize_win_admin_user((host.mgmt_user or "").strip())
|
||||||
|
host_password = (host.mgmt_password or "").strip()
|
||||||
|
if host_user and host_password:
|
||||||
|
return WinAdminConfig(user=host_user, password=host_password, source="host")
|
||||||
|
|
||||||
|
global_cfg = get_effective_win_admin_config(db)
|
||||||
|
# Allow host to override only the username, still using global password (rare).
|
||||||
|
if host_user and global_cfg.password.strip():
|
||||||
|
return WinAdminConfig(user=host_user, password=global_cfg.password, source="host")
|
||||||
|
return global_cfg
|
||||||
|
|||||||
@@ -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.5.16"
|
APP_VERSION = "0.5.17"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Per-host management credential override."""
|
||||||
|
|
||||||
|
from app.models.host import Host
|
||||||
|
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||||
|
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||||
|
from app.services.host_mgmt_credentials import get_host_mgmt_access_view, upsert_host_mgmt_credentials
|
||||||
|
|
||||||
|
|
||||||
|
def test_win_admin_for_host_prefers_override(db_session):
|
||||||
|
host = Host(
|
||||||
|
hostname="HOME-PC",
|
||||||
|
os_family="windows",
|
||||||
|
product="rdp-login-monitor",
|
||||||
|
mgmt_user=".\\Admin",
|
||||||
|
mgmt_password="local-secret",
|
||||||
|
)
|
||||||
|
db_session.add(host)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(host)
|
||||||
|
|
||||||
|
cfg = get_effective_win_admin_for_host(db_session, host)
|
||||||
|
assert cfg.configured is True
|
||||||
|
assert cfg.source == "host"
|
||||||
|
assert cfg.user == ".\\Admin"
|
||||||
|
assert cfg.password == "local-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_linux_admin_for_host_prefers_override(db_session):
|
||||||
|
host = Host(
|
||||||
|
hostname="homeserver",
|
||||||
|
os_family="linux",
|
||||||
|
product="ssh-monitor",
|
||||||
|
mgmt_user="deploy",
|
||||||
|
mgmt_password="ssh-pass",
|
||||||
|
)
|
||||||
|
db_session.add(host)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(host)
|
||||||
|
|
||||||
|
cfg = get_effective_linux_admin_for_host(db_session, host)
|
||||||
|
assert cfg.source == "host"
|
||||||
|
assert cfg.user == "deploy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_and_clear_host_access(db_session):
|
||||||
|
host = Host(hostname="box", os_family="windows", product="rdp-login-monitor")
|
||||||
|
db_session.add(host)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(host)
|
||||||
|
|
||||||
|
view = upsert_host_mgmt_credentials(
|
||||||
|
db_session,
|
||||||
|
host,
|
||||||
|
user="HOME\\user",
|
||||||
|
password="secret123",
|
||||||
|
)
|
||||||
|
assert view.has_override is True
|
||||||
|
assert view.password_set is True
|
||||||
|
assert view.user == "HOME\\user"
|
||||||
|
|
||||||
|
cleared = upsert_host_mgmt_credentials(db_session, host, clear=True)
|
||||||
|
assert cleared.has_override is False
|
||||||
|
assert cleared.password_set is False
|
||||||
|
assert get_host_mgmt_access_view(db_session, host).user is None
|
||||||
@@ -183,11 +183,13 @@ sequenceDiagram
|
|||||||
|
|
||||||
## 5. П.2C — Доступ SAC к хостам
|
## 5. П.2C — Доступ SAC к хостам
|
||||||
|
|
||||||
### 5.1. Windows — WinRM
|
### 5.1. Windows — WinRM / учётки
|
||||||
|
|
||||||
- **Один доменный admin** в **Настройки → Управление хостами → Windows**: `DOMAIN\user` + password (encrypted).
|
- **Глобальный доменный admin** в **Настройки → Windows**: `DOMAIN\user` + password (encrypted). Аналогично **Linux admin** для SSH.
|
||||||
- Override на карточке хоста — опционально позже.
|
- **Override на карточке хоста** (**Хосты → WinRM/SSH / доступ к хосту**): `COMPUTER\user` / `.\Administrator` + password (encrypted в `hosts.mgmt_*`). Если пусто — берутся глобальные.
|
||||||
- Используется для fallback-обновления и (при необходимости) remote ops; для qwinsta/logoff MVP — **через агента** с теми же creds, переданными в command poll (TLS + API key, не пишутся на диск агента).
|
- Effective creds: `get_effective_win_admin_for_host` / `get_effective_linux_admin_for_host` (source: `host` | `db` | `env`).
|
||||||
|
- API: `GET` / `PUT /api/v1/hosts/{id}/access` (`user`, `password`, `clear`).
|
||||||
|
- Используются для WinRM/SSH-test, fallback-update, qwinsta/logoff, remote ops; для run_as через агента — те же creds в command poll (TLS + API key, не пишутся на диск агента).
|
||||||
|
|
||||||
### 5.2. Linux — bootstrap password → SSH key → удалить password
|
### 5.2. Linux — bootstrap password → SSH key → удалить password
|
||||||
|
|
||||||
@@ -266,7 +268,7 @@ Authorization: Bearer sac_xxx
|
|||||||
| POST | `/api/v1/events/{id}/actions/logoff` | logoff `{ "session_id": 5 }` |
|
| POST | `/api/v1/events/{id}/actions/logoff` | logoff `{ "session_id": 5 }` |
|
||||||
| GET | `/api/v1/events/{id}/actions/{cmd_id}` | Статус / результат |
|
| GET | `/api/v1/events/{id}/actions/{cmd_id}` | Статус / результат |
|
||||||
| PATCH | `/api/v1/hosts/{id}/config` | Desired config |
|
| PATCH | `/api/v1/hosts/{id}/config` | Desired config |
|
||||||
| PATCH | `/api/v1/hosts/{id}/access` | Bootstrap / WinRM creds |
|
| GET/PUT | `/api/v1/hosts/{id}/access` | Per-host WinRM/SSH override (mgmt_user/password) |
|
||||||
| GET/PATCH | `/api/v1/settings/agent-updates` | Режим A/B, версии, источники |
|
| GET/PATCH | `/api/v1/settings/agent-updates` | Режим A/B, версии, источники |
|
||||||
| GET/PATCH | `/api/v1/settings/host-management` | Доменный admin Windows |
|
| GET/PATCH | `/api/v1/settings/host-management` | Доменный admin Windows |
|
||||||
|
|
||||||
|
|||||||
@@ -532,6 +532,31 @@ export function updateLinuxAdminSettings(payload: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HostMgmtAccess {
|
||||||
|
host_id: number;
|
||||||
|
has_override: boolean;
|
||||||
|
user: string | null;
|
||||||
|
password_set: boolean;
|
||||||
|
password_hint: string | null;
|
||||||
|
effective_source: string;
|
||||||
|
effective_configured: boolean;
|
||||||
|
effective_user: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchHostAccess(hostId: number): Promise<HostMgmtAccess> {
|
||||||
|
return apiFetch<HostMgmtAccess>(`/api/v1/hosts/${hostId}/access`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateHostAccess(
|
||||||
|
hostId: number,
|
||||||
|
payload: { user?: string | null; password?: string | null; clear?: boolean },
|
||||||
|
): Promise<HostMgmtAccess> {
|
||||||
|
return apiFetch<HostMgmtAccess>(`/api/v1/hosts/${hostId}/access`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostSshActionResult {
|
export interface HostSshActionResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
|
|||||||
@@ -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.5.16";
|
export const APP_VERSION = "0.5.17";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -69,6 +69,52 @@
|
|||||||
<dd>{{ host.event_count ?? 0 }}</dd>
|
<dd>{{ host.event_count ?? 0 }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<section v-if="showAgentConfig" class="host-mgmt-access">
|
||||||
|
<h3>{{ isWindowsHost ? "WinRM / доступ к хосту" : "SSH / доступ к хосту" }}</h3>
|
||||||
|
<p class="muted">
|
||||||
|
Переопределение для этого ПК. Если пусто — используются глобальные учётные данные из
|
||||||
|
<RouterLink to="/settings">Настройки</RouterLink>
|
||||||
|
({{ isWindowsHost ? "Windows admin" : "Linux admin" }}).
|
||||||
|
Сейчас effective:
|
||||||
|
<strong>{{ accessEffectiveLabel }}</strong>
|
||||||
|
</p>
|
||||||
|
<form class="agent-config-form" @submit.prevent="saveHostAccess">
|
||||||
|
<label class="config-field">
|
||||||
|
Логин
|
||||||
|
<input
|
||||||
|
v-model="accessForm.user"
|
||||||
|
type="text"
|
||||||
|
:placeholder="isWindowsHost ? 'COMPUTER\\user или .\\Administrator' : 'root'"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="config-field">
|
||||||
|
Пароль
|
||||||
|
<input
|
||||||
|
v-model="accessForm.password"
|
||||||
|
type="password"
|
||||||
|
:placeholder="accessForm.passwordSet ? '•••• (оставьте пустым, чтобы не менять)' : ''"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="host-actions-row">
|
||||||
|
<button type="submit" :disabled="savingAccess">
|
||||||
|
{{ savingAccess ? "Сохранение…" : "Сохранить доступ" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="savingAccess || !accessForm.hasOverride"
|
||||||
|
@click="clearHostAccess"
|
||||||
|
>
|
||||||
|
Сбросить (глобальные)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="accessMessage" :class="accessOk ? 'success' : 'error'">{{ accessMessage }}</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div v-if="isWindowsHost" class="host-actions">
|
<div v-if="isWindowsHost" class="host-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -296,6 +342,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
apiFetch,
|
apiFetch,
|
||||||
fetchHost,
|
fetchHost,
|
||||||
|
fetchHostAccess,
|
||||||
|
updateHostAccess,
|
||||||
fetchRecentEvents,
|
fetchRecentEvents,
|
||||||
testHostWinRm,
|
testHostWinRm,
|
||||||
testHostSsh,
|
testHostSsh,
|
||||||
@@ -303,6 +351,7 @@ import {
|
|||||||
patchHostAgentConfig,
|
patchHostAgentConfig,
|
||||||
type EventListResponse,
|
type EventListResponse,
|
||||||
type HostDetail,
|
type HostDetail,
|
||||||
|
type HostMgmtAccess,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { emitHostPatch } from "../utils/hostPatchBus";
|
import { emitHostPatch } from "../utils/hostPatchBus";
|
||||||
import HostSessionsPanel from "../components/HostSessionsPanel.vue";
|
import HostSessionsPanel from "../components/HostSessionsPanel.vue";
|
||||||
@@ -354,6 +403,32 @@ const configForm = reactive({
|
|||||||
serverDisplayName: "",
|
serverDisplayName: "",
|
||||||
getInventory: true,
|
getInventory: true,
|
||||||
});
|
});
|
||||||
|
const savingAccess = ref(false);
|
||||||
|
const accessMessage = ref("");
|
||||||
|
const accessOk = ref(false);
|
||||||
|
const accessForm = reactive({
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
passwordSet: false,
|
||||||
|
hasOverride: false,
|
||||||
|
effectiveSource: "",
|
||||||
|
effectiveUser: "",
|
||||||
|
effectiveConfigured: false,
|
||||||
|
});
|
||||||
|
const accessEffectiveLabel = computed(() => {
|
||||||
|
if (!accessForm.effectiveConfigured) {
|
||||||
|
return "не настроено";
|
||||||
|
}
|
||||||
|
const src =
|
||||||
|
accessForm.effectiveSource === "host"
|
||||||
|
? "override хоста"
|
||||||
|
: accessForm.effectiveSource === "db"
|
||||||
|
? "глобальные (Settings)"
|
||||||
|
: accessForm.effectiveSource === "env"
|
||||||
|
? "env"
|
||||||
|
: accessForm.effectiveSource;
|
||||||
|
return `${accessForm.effectiveUser || "—"} · ${src}`;
|
||||||
|
});
|
||||||
let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
let sshTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
let winRmTestHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let sshProbeSeq = 0;
|
let sshProbeSeq = 0;
|
||||||
@@ -678,6 +753,67 @@ async function saveAgentConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyAccessView(view: HostMgmtAccess) {
|
||||||
|
accessForm.user = view.user || "";
|
||||||
|
accessForm.password = "";
|
||||||
|
accessForm.passwordSet = view.password_set;
|
||||||
|
accessForm.hasOverride = view.has_override;
|
||||||
|
accessForm.effectiveSource = view.effective_source;
|
||||||
|
accessForm.effectiveUser = view.effective_user || "";
|
||||||
|
accessForm.effectiveConfigured = view.effective_configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHostAccess() {
|
||||||
|
accessMessage.value = "";
|
||||||
|
try {
|
||||||
|
const view = await fetchHostAccess(hostId.value);
|
||||||
|
applyAccessView(view);
|
||||||
|
} catch (e) {
|
||||||
|
accessOk.value = false;
|
||||||
|
accessMessage.value = e instanceof Error ? e.message : "Не удалось загрузить доступ хоста";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHostAccess() {
|
||||||
|
savingAccess.value = true;
|
||||||
|
accessMessage.value = "";
|
||||||
|
try {
|
||||||
|
const payload: { user?: string | null; password?: string | null } = {
|
||||||
|
user: accessForm.user,
|
||||||
|
};
|
||||||
|
if (accessForm.password.trim()) {
|
||||||
|
payload.password = accessForm.password;
|
||||||
|
}
|
||||||
|
const view = await updateHostAccess(hostId.value, payload);
|
||||||
|
applyAccessView(view);
|
||||||
|
accessOk.value = true;
|
||||||
|
accessMessage.value = view.has_override
|
||||||
|
? "Сохранено (override хоста)"
|
||||||
|
: "Сохранено (логин очищен — глобальные настройки)";
|
||||||
|
} catch (e) {
|
||||||
|
accessOk.value = false;
|
||||||
|
accessMessage.value = e instanceof Error ? e.message : "Ошибка сохранения доступа";
|
||||||
|
} finally {
|
||||||
|
savingAccess.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearHostAccess() {
|
||||||
|
savingAccess.value = true;
|
||||||
|
accessMessage.value = "";
|
||||||
|
try {
|
||||||
|
const view = await updateHostAccess(hostId.value, { clear: true });
|
||||||
|
applyAccessView(view);
|
||||||
|
accessOk.value = true;
|
||||||
|
accessMessage.value = "Override сброшен — используются глобальные настройки";
|
||||||
|
} catch (e) {
|
||||||
|
accessOk.value = false;
|
||||||
|
accessMessage.value = e instanceof Error ? e.message : "Ошибка сброса доступа";
|
||||||
|
} finally {
|
||||||
|
savingAccess.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadHost() {
|
async function loadHost() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -692,6 +828,7 @@ async function loadHost() {
|
|||||||
try {
|
try {
|
||||||
const detail = await fetchHost(hostId.value);
|
const detail = await fetchHost(hostId.value);
|
||||||
applyHostDetail(detail);
|
applyHostDetail(detail);
|
||||||
|
void loadHostAccess();
|
||||||
if (isLinuxHostDetail(detail)) {
|
if (isLinuxHostDetail(detail)) {
|
||||||
void probeSshOnOpen();
|
void probeSshOnOpen();
|
||||||
}
|
}
|
||||||
@@ -848,6 +985,49 @@ watch(
|
|||||||
color: #9aa4b2;
|
color: #9aa4b2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.host-mgmt-access {
|
||||||
|
margin: 1rem 0 1.25rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid #2a3340;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #141a22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-mgmt-access h3 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-mgmt-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
max-width: 28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-mgmt-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-mgmt-field input {
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #3a4554;
|
||||||
|
background: #0f1419;
|
||||||
|
color: #e8eef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-mgmt-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.host-actions {
|
.host-actions {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -126,8 +126,9 @@
|
|||||||
<section class="card settings-card settings-policy">
|
<section class="card settings-card settings-policy">
|
||||||
<h2>Windows (qwinsta / logoff)</h2>
|
<h2>Windows (qwinsta / logoff)</h2>
|
||||||
<p class="settings-hint settings-hint-top">
|
<p class="settings-hint settings-hint-top">
|
||||||
Доменный admin для удалённых команд на Windows-хостах (qwinsta, logoff через агент).
|
Глобальный (доменный) admin для удалённых команд на Windows-хостах (qwinsta, logoff, WinRM-update).
|
||||||
Формат логина: <code>ДОМЕН\пользователь</code> (например <code>B26\papatramp</code>).
|
Формат логина: <code>ДОМЕН\пользователь</code> (например <code>B26\papatramp</code>).
|
||||||
|
Для отдельного ПК (home/workgroup) задайте override в карточке <strong>Хосты → доступ к хосту</strong>.
|
||||||
Пока запись не создана в UI — используются <code>SAC_WIN_ADMIN_*</code> из <code>sac-api.env</code>.
|
Пока запись не создана в UI — используются <code>SAC_WIN_ADMIN_*</code> из <code>sac-api.env</code>.
|
||||||
</p>
|
</p>
|
||||||
<form class="settings-form" @submit.prevent="saveWinAdminSettings">
|
<form class="settings-form" @submit.prevent="saveWinAdminSettings">
|
||||||
@@ -186,8 +187,9 @@
|
|||||||
<section class="card settings-card settings-policy">
|
<section class="card settings-card settings-policy">
|
||||||
<h2>Linux (SSH / обновление ssh-monitor)</h2>
|
<h2>Linux (SSH / обновление ssh-monitor)</h2>
|
||||||
<p class="settings-hint settings-hint-top">
|
<p class="settings-hint settings-hint-top">
|
||||||
SSH-учётка для удалённого запуска <code>/opt/scripts/update_ssh_monitor.sh</code> с карточки Linux-хоста.
|
Глобальная SSH-учётка для удалённого запуска <code>/opt/scripts/update_ssh_monitor.sh</code> с карточки Linux-хоста.
|
||||||
Рекомендуется <code>root</code> или пользователь с <code>sudo NOPASSWD</code>.
|
Рекомендуется <code>root</code> или пользователь с <code>sudo NOPASSWD</code>.
|
||||||
|
Для отдельного сервера задайте override в карточке <strong>Хосты → доступ к хосту</strong>.
|
||||||
Пока запись не создана в UI — используются <code>SAC_LINUX_ADMIN_*</code> из <code>sac-api.env</code>.
|
Пока запись не создана в UI — используются <code>SAC_LINUX_ADMIN_*</code> из <code>sac-api.env</code>.
|
||||||
</p>
|
</p>
|
||||||
<form class="settings-form" @submit.prevent="saveLinuxAdminSettings">
|
<form class="settings-form" @submit.prevent="saveLinuxAdminSettings">
|
||||||
|
|||||||
Reference in New Issue
Block a user