chore(home): mirror from kalinamall (9883e6a) with papatramp URLs
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""Queue and resolve agent commands (legacy poll path + helpers)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AgentCommand, Host
|
||||
from app.services.win_admin_settings import (
|
||||
get_effective_win_admin_config,
|
||||
get_effective_win_admin_for_host,
|
||||
)
|
||||
|
||||
|
||||
def _win_admin_configured() -> bool:
|
||||
return get_effective_win_admin_config().configured
|
||||
|
||||
|
||||
def win_admin_run_as(db: Session | None = None, host: Host | None = None) -> dict[str, str] | None:
|
||||
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:
|
||||
return None
|
||||
return {
|
||||
"user": cfg.user,
|
||||
"password": cfg.password,
|
||||
}
|
||||
|
||||
|
||||
def command_to_dict(
|
||||
cmd: AgentCommand,
|
||||
*,
|
||||
include_run_as: bool = False,
|
||||
db: Session | None = None,
|
||||
host: Host | None = None,
|
||||
) -> dict:
|
||||
out = {
|
||||
"id": cmd.command_uuid,
|
||||
"type": cmd.command_type,
|
||||
"params": cmd.params if isinstance(cmd.params, dict) else {},
|
||||
"status": cmd.status,
|
||||
"result_stdout": cmd.result_stdout,
|
||||
"result_stderr": cmd.result_stderr,
|
||||
"created_at": cmd.created_at.isoformat() if cmd.created_at else None,
|
||||
"completed_at": cmd.completed_at.isoformat() if cmd.completed_at else None,
|
||||
}
|
||||
if include_run_as and cmd.status == "pending":
|
||||
run_as = win_admin_run_as(db, host)
|
||||
if run_as:
|
||||
out["run_as"] = run_as
|
||||
return out
|
||||
|
||||
|
||||
def command_response_fields(cmd: AgentCommand) -> dict:
|
||||
params = cmd.params if isinstance(cmd.params, dict) else {}
|
||||
return {
|
||||
"target": params.get("winrm_target") or params.get("client_hostname"),
|
||||
"client_hostname": params.get("client_hostname"),
|
||||
"internal_ip": params.get("internal_ip"),
|
||||
}
|
||||
|
||||
|
||||
def get_command_for_host_poll(db: Session, host: Host, *, limit: int = 5) -> list[AgentCommand]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(AgentCommand)
|
||||
.where(
|
||||
AgentCommand.host_id == host.id,
|
||||
AgentCommand.status == "pending",
|
||||
)
|
||||
.order_by(AgentCommand.created_at.asc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def resolve_host_by_agent_instance_id(db: Session, agent_instance_id: str) -> Host | None:
|
||||
if not agent_instance_id.strip():
|
||||
return None
|
||||
return db.scalar(select(Host).where(Host.agent_instance_id == agent_instance_id.strip()))
|
||||
|
||||
|
||||
def complete_command(
|
||||
db: Session,
|
||||
command_uuid: str,
|
||||
*,
|
||||
status: str,
|
||||
stdout: str | None = None,
|
||||
stderr: str | None = None,
|
||||
) -> AgentCommand | None:
|
||||
cmd = db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
|
||||
if cmd is None:
|
||||
return None
|
||||
if cmd.status != "pending":
|
||||
return cmd
|
||||
cmd.status = status
|
||||
cmd.result_stdout = stdout
|
||||
cmd.result_stderr = stderr
|
||||
cmd.completed_at = datetime.now(timezone.utc)
|
||||
db.flush()
|
||||
return cmd
|
||||
|
||||
|
||||
def get_command_by_uuid(db: Session, command_uuid: str) -> AgentCommand | None:
|
||||
return db.scalar(select(AgentCommand).where(AgentCommand.command_uuid == command_uuid))
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Fetch latest agent release versions from git repositories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig
|
||||
from app.services.agent_version import parse_agent_version
|
||||
|
||||
_SCRIPT_VERSION_RE = re.compile(r'\$ScriptVersion\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE)
|
||||
_SSH_VERSION_RE = re.compile(r'^SSH_MONITOR_VERSION=["\']([^"\']+)["\']', re.MULTILINE)
|
||||
|
||||
_MEMORY_CACHE: dict[str, tuple[dict[str, str], float, str]] = {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitReleaseVersions:
|
||||
versions: dict[str, str]
|
||||
fetched_at: datetime | None
|
||||
from_cache: bool
|
||||
errors: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def normalize_git_repo_url(raw: str) -> str:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if not text.startswith(("http://", "https://", "git@")):
|
||||
text = f"https://{text.lstrip('/')}"
|
||||
if text.startswith("git@"):
|
||||
return text
|
||||
if not text.endswith(".git"):
|
||||
text = f"{text.rstrip('/')}.git"
|
||||
return text
|
||||
|
||||
|
||||
def git_repo_url_with_auth(repo_url: str, token: str) -> str:
|
||||
normalized = normalize_git_repo_url(repo_url)
|
||||
if not token or not normalized.startswith("https://"):
|
||||
return normalized
|
||||
parsed = urlparse(normalized)
|
||||
host = parsed.netloc
|
||||
path = parsed.path or ""
|
||||
return f"https://oauth2:{token}@{host}{path}"
|
||||
|
||||
|
||||
def _cache_key(cfg: AgentUpdateConfig) -> str:
|
||||
parts = [
|
||||
cfg.rdp_git_repo_url,
|
||||
cfg.ssh_git_repo_url,
|
||||
cfg.git_branch,
|
||||
]
|
||||
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str | None:
|
||||
if not path.is_file():
|
||||
return None
|
||||
return path.read_text(encoding="utf-8-sig")
|
||||
|
||||
|
||||
def parse_rdp_version_from_files(version_txt: str | None, login_monitor_ps1: str | None) -> str | None:
|
||||
if version_txt:
|
||||
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
|
||||
if parse_agent_version(first_line):
|
||||
return first_line
|
||||
if login_monitor_ps1:
|
||||
match = _SCRIPT_VERSION_RE.search(login_monitor_ps1)
|
||||
if match:
|
||||
candidate = match.group(1).strip()
|
||||
if parse_agent_version(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def parse_ssh_version_from_files(ssh_monitor: str | None, version_txt: str | None) -> str | None:
|
||||
if ssh_monitor:
|
||||
match = _SSH_VERSION_RE.search(ssh_monitor)
|
||||
if match:
|
||||
candidate = match.group(1).strip()
|
||||
if parse_agent_version(candidate):
|
||||
return candidate
|
||||
if version_txt:
|
||||
first_line = version_txt.strip().splitlines()[0].strip() if version_txt.strip() else ""
|
||||
if parse_agent_version(first_line):
|
||||
return first_line
|
||||
return None
|
||||
|
||||
|
||||
def parse_product_version_from_dir(repo_dir: Path, product: str) -> str | None:
|
||||
if product == PRODUCT_RDP:
|
||||
return parse_rdp_version_from_files(
|
||||
_read_text(repo_dir / "version.txt"),
|
||||
_read_text(repo_dir / "Login_Monitor.ps1"),
|
||||
)
|
||||
if product == PRODUCT_SSH:
|
||||
return parse_ssh_version_from_files(
|
||||
_read_text(repo_dir / "ssh-monitor"),
|
||||
_read_text(repo_dir / "version.txt"),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _run_git(args: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _repo_slug(repo_url: str) -> str:
|
||||
normalized = normalize_git_repo_url(repo_url)
|
||||
name = normalized.rstrip("/").split("/")[-1]
|
||||
if name.endswith(".git"):
|
||||
name = name[:-4]
|
||||
digest = hashlib.sha256(normalized.encode()).hexdigest()[:12]
|
||||
return f"{name}-{digest}"
|
||||
|
||||
|
||||
def clone_or_update_repo(repo_url: str, branch: str, cache_dir: Path, token: str = "") -> Path:
|
||||
clone_url = git_repo_url_with_auth(repo_url, token)
|
||||
normalized = normalize_git_repo_url(repo_url)
|
||||
if not normalized:
|
||||
raise ValueError("empty repo url")
|
||||
|
||||
branch_name = (branch or "main").strip() or "main"
|
||||
target = cache_dir / _repo_slug(normalized)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if target.is_dir() and (target / ".git").is_dir():
|
||||
fetch = _run_git(["fetch", "--depth", "1", "origin", branch_name], cwd=target)
|
||||
if fetch.returncode != 0:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
else:
|
||||
checkout = _run_git(["checkout", "FETCH_HEAD"], cwd=target)
|
||||
if checkout.returncode == 0:
|
||||
return target
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
|
||||
clone = _run_git(
|
||||
["clone", "--depth", "1", "--branch", branch_name, clone_url, str(target)],
|
||||
)
|
||||
if clone.returncode != 0:
|
||||
err = (clone.stderr or clone.stdout or "git clone failed").strip()
|
||||
raise RuntimeError(err)
|
||||
return target
|
||||
|
||||
|
||||
def fetch_product_version(repo_url: str, branch: str, product: str, cache_dir: Path, token: str = "") -> str:
|
||||
repo_dir = clone_or_update_repo(repo_url, branch, cache_dir, token=token)
|
||||
version = parse_product_version_from_dir(repo_dir, product)
|
||||
if not version:
|
||||
raise RuntimeError(f"version not found in {repo_url} ({product})")
|
||||
return version
|
||||
|
||||
|
||||
def recommended_from_git(cfg: AgentUpdateConfig, git_versions: dict[str, str], product: str) -> str:
|
||||
git_version = (git_versions.get(product) or "").strip()
|
||||
if git_version:
|
||||
return git_version
|
||||
manual = cfg.recommended_for_product(product)
|
||||
return manual
|
||||
|
||||
|
||||
def _ttl_seconds() -> int:
|
||||
settings = get_settings()
|
||||
return max(60, int(settings.sac_agent_git_cache_ttl_minutes) * 60)
|
||||
|
||||
|
||||
def _cache_dir() -> Path:
|
||||
settings = get_settings()
|
||||
return Path(settings.sac_agent_git_cache_dir or "/tmp/sac-agent-repos")
|
||||
|
||||
|
||||
def _db_cache_fresh(row: UiSettings | None, cfg: AgentUpdateConfig) -> bool:
|
||||
if row is None or row.agent_git_fetched_at is None:
|
||||
return False
|
||||
fetched_at = row.agent_git_fetched_at
|
||||
if fetched_at.tzinfo is None:
|
||||
fetched_at = fetched_at.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - fetched_at).total_seconds()
|
||||
if age > _ttl_seconds():
|
||||
return False
|
||||
if (row.agent_rdp_git_repo_url or "").strip() != cfg.rdp_git_repo_url:
|
||||
return False
|
||||
if (row.agent_ssh_git_repo_url or "").strip() != cfg.ssh_git_repo_url:
|
||||
return False
|
||||
if (row.agent_git_branch or "main").strip() != cfg.git_branch:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _versions_from_db_row(row: UiSettings) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
rdp = (row.agent_git_rdp_version or "").strip()
|
||||
ssh = (row.agent_git_ssh_version or "").strip()
|
||||
if rdp:
|
||||
out[PRODUCT_RDP] = rdp
|
||||
if ssh:
|
||||
out[PRODUCT_SSH] = ssh
|
||||
return out
|
||||
|
||||
|
||||
def _persist_db_cache(db: Session, cfg: AgentUpdateConfig, versions: dict[str, str]) -> datetime:
|
||||
now = datetime.now(timezone.utc)
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
|
||||
db.add(row)
|
||||
row.agent_rdp_git_repo_url = cfg.rdp_git_repo_url or None
|
||||
row.agent_ssh_git_repo_url = cfg.ssh_git_repo_url or None
|
||||
row.agent_git_branch = cfg.git_branch or "main"
|
||||
row.agent_git_rdp_version = versions.get(PRODUCT_RDP) or None
|
||||
row.agent_git_ssh_version = versions.get(PRODUCT_SSH) or None
|
||||
row.agent_git_fetched_at = now
|
||||
db.commit()
|
||||
return now
|
||||
|
||||
|
||||
def get_git_release_versions(
|
||||
cfg: AgentUpdateConfig,
|
||||
*,
|
||||
db: Session | None = None,
|
||||
force_refresh: bool = False,
|
||||
) -> GitReleaseVersions:
|
||||
key = _cache_key(cfg)
|
||||
now_ts = time.time()
|
||||
ttl = _ttl_seconds()
|
||||
|
||||
if not force_refresh:
|
||||
cached = _MEMORY_CACHE.get(key)
|
||||
if cached and now_ts - cached[1] <= ttl:
|
||||
fetched_at = datetime.fromtimestamp(cached[1], tz=timezone.utc)
|
||||
return GitReleaseVersions(versions=dict(cached[0]), fetched_at=fetched_at, from_cache=True)
|
||||
|
||||
if db is not None:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if _db_cache_fresh(row, cfg):
|
||||
versions = _versions_from_db_row(row)
|
||||
if versions:
|
||||
_MEMORY_CACHE[key] = (versions, row.agent_git_fetched_at.timestamp(), key)
|
||||
return GitReleaseVersions(
|
||||
versions=versions,
|
||||
fetched_at=row.agent_git_fetched_at,
|
||||
from_cache=True,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
cache_dir = _cache_dir()
|
||||
token = (settings.sac_agent_git_token or "").strip()
|
||||
branch = cfg.git_branch or "main"
|
||||
versions: dict[str, str] = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
product_repo = {
|
||||
PRODUCT_RDP: cfg.rdp_git_repo_url,
|
||||
PRODUCT_SSH: cfg.ssh_git_repo_url,
|
||||
}
|
||||
for product, repo_url in product_repo.items():
|
||||
if not repo_url:
|
||||
errors[product] = "repo url not configured"
|
||||
continue
|
||||
try:
|
||||
versions[product] = fetch_product_version(repo_url, branch, product, cache_dir, token=token)
|
||||
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as exc:
|
||||
errors[product] = str(exc).strip() or "fetch failed"
|
||||
|
||||
fetched_at: datetime | None = None
|
||||
if versions:
|
||||
fetched_at = datetime.now(timezone.utc)
|
||||
_MEMORY_CACHE[key] = (dict(versions), fetched_at.timestamp(), key)
|
||||
if db is not None:
|
||||
fetched_at = _persist_db_cache(db, cfg, versions)
|
||||
|
||||
if not versions and db is not None:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is not None:
|
||||
stale = _versions_from_db_row(row)
|
||||
if stale:
|
||||
return GitReleaseVersions(
|
||||
versions=stale,
|
||||
fetched_at=row.agent_git_fetched_at,
|
||||
from_cache=True,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return GitReleaseVersions(
|
||||
versions=versions,
|
||||
fetched_at=fetched_at,
|
||||
from_cache=False,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Desired agent config per host (poll delivery)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.models import Host
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
|
||||
RDP_CONFIG_KEYS = frozenset(
|
||||
{
|
||||
"ServerDisplayName",
|
||||
"EnableRcmShadowControlMonitoring",
|
||||
"EnableWinRmInboundMonitoring",
|
||||
"EnableAdminShareMonitoring",
|
||||
"GetInventory",
|
||||
"SacCommandPollIntervalSec",
|
||||
}
|
||||
)
|
||||
SSH_CONFIG_KEYS = frozenset(
|
||||
{
|
||||
"SERVER_DISPLAY_NAME",
|
||||
"HEARTBEAT_INTERVAL",
|
||||
"DAILY_REPORT_ENABLED",
|
||||
"UseSAC",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _allowed_keys(host: Host) -> frozenset[str]:
|
||||
product = (host.product or "").strip()
|
||||
if product == PRODUCT_RDP or (host.os_family or "").strip().lower() == "windows":
|
||||
return RDP_CONFIG_KEYS
|
||||
if product == PRODUCT_SSH or (host.os_family or "").strip().lower() == "linux":
|
||||
return SSH_CONFIG_KEYS
|
||||
return frozenset()
|
||||
|
||||
|
||||
def get_host_agent_settings(host: Host) -> dict:
|
||||
raw = host.agent_config if isinstance(host.agent_config, dict) else {}
|
||||
allowed = _allowed_keys(host)
|
||||
if not allowed:
|
||||
return {}
|
||||
return {k: v for k, v in raw.items() if k in allowed}
|
||||
|
||||
|
||||
def build_agent_config_payload(host: Host) -> dict:
|
||||
settings = get_host_agent_settings(host)
|
||||
if not settings:
|
||||
return {}
|
||||
return {"settings": settings}
|
||||
|
||||
|
||||
def update_host_agent_config(db: Session, host: Host, settings: dict) -> Host:
|
||||
allowed = _allowed_keys(host)
|
||||
if not allowed:
|
||||
raise ValueError("Host product does not support desired agent config")
|
||||
|
||||
current = host.agent_config if isinstance(host.agent_config, dict) else {}
|
||||
merged = dict(current)
|
||||
for key, value in settings.items():
|
||||
if key not in allowed:
|
||||
raise ValueError(f"Unsupported config key: {key}")
|
||||
if value is None:
|
||||
merged.pop(key, None)
|
||||
else:
|
||||
merged[key] = value
|
||||
|
||||
host.agent_config = merged
|
||||
host.agent_config_revision = int(host.agent_config_revision or 0) + 1
|
||||
flag_modified(host, "agent_config")
|
||||
db.flush()
|
||||
return host
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Agent poll payload: commands, desired config, pending update."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Host
|
||||
from app.services.agent_commands import command_to_dict, get_command_for_host_poll
|
||||
from app.services.agent_host_config import build_agent_config_payload
|
||||
from app.services.agent_update_settings import get_effective_agent_update_config
|
||||
|
||||
|
||||
def build_agent_poll_payload(db: Session, host: Host) -> dict[str, Any]:
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
pending = get_command_for_host_poll(db, host)
|
||||
config_payload = build_agent_config_payload(host)
|
||||
|
||||
update_block: dict[str, Any] = {
|
||||
"requested": bool(host.pending_agent_update),
|
||||
"target_version": host.pending_update_target_version,
|
||||
"source": "sac" if cfg.sac_managed else "gpo",
|
||||
}
|
||||
|
||||
return {
|
||||
"config_revision": int(host.agent_config_revision or 0),
|
||||
"config": config_payload,
|
||||
"update": update_block,
|
||||
"commands": [command_to_dict(c, include_run_as=True, db=db, host=host) for c in pending],
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Request agent self-update, ingest lifecycle, SSH/WinRM fallback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Host
|
||||
from app.services.agent_update_types import AgentUpdateFallbackResult
|
||||
from app.services.agent_update_settings import (
|
||||
PRODUCT_RDP,
|
||||
PRODUCT_SSH,
|
||||
AgentUpdateConfig,
|
||||
get_effective_agent_update_config,
|
||||
)
|
||||
from app.services.agent_git_release import get_git_release_versions, recommended_from_git
|
||||
from app.services.agent_version import (
|
||||
host_version_outdated,
|
||||
latest_agent_versions_by_product,
|
||||
)
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
|
||||
from app.services.ssh_connect import (
|
||||
HostNotLinuxError,
|
||||
HostTargetMissingError,
|
||||
is_linux_host,
|
||||
iter_ssh_targets,
|
||||
run_ssh_monitor_update,
|
||||
)
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError as WinRmHostTargetMissingError,
|
||||
is_windows_host,
|
||||
iter_winrm_targets,
|
||||
run_winrm_on_host_targets,
|
||||
run_winrm_rdp_monitor_update,
|
||||
)
|
||||
|
||||
AGENT_UPDATE_STARTED = "agent.update.started"
|
||||
AGENT_UPDATE_SUCCESS = "agent.update.success"
|
||||
AGENT_UPDATE_FAILED = "agent.update.failed"
|
||||
AGENT_UPDATE_TYPES = frozenset(
|
||||
{AGENT_UPDATE_STARTED, AGENT_UPDATE_SUCCESS, AGENT_UPDATE_FAILED}
|
||||
)
|
||||
|
||||
|
||||
class AgentUpdateNotAllowedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_target_version(
|
||||
db: Session,
|
||||
host: Host,
|
||||
cfg: AgentUpdateConfig,
|
||||
*,
|
||||
git_versions: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
product = host.product or ""
|
||||
if git_versions is None:
|
||||
git_versions = get_git_release_versions(cfg, db=db).versions
|
||||
git_target = recommended_from_git(cfg, git_versions, product)
|
||||
if git_target:
|
||||
return git_target
|
||||
latest_map = latest_agent_versions_by_product(db)
|
||||
return latest_map.get(product, "") or (host.product_version or "")
|
||||
|
||||
|
||||
def host_version_status(
|
||||
host: Host,
|
||||
*,
|
||||
cfg: AgentUpdateConfig,
|
||||
latest_map: dict[str, str],
|
||||
git_versions: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
product = host.product or ""
|
||||
if git_versions is None:
|
||||
git_versions = get_git_release_versions(cfg).versions
|
||||
outdated = host_version_outdated(
|
||||
host.product_version,
|
||||
recommended=recommended_from_git(cfg, git_versions, product),
|
||||
fleet_latest=latest_map.get(product, ""),
|
||||
min_version=cfg.min_for_product(product),
|
||||
)
|
||||
if outdated is None:
|
||||
return "unknown"
|
||||
return "outdated" if outdated else "ok"
|
||||
|
||||
|
||||
def request_agent_update(db: Session, host: Host) -> Host:
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
if not cfg.sac_managed:
|
||||
raise AgentUpdateNotAllowedError(
|
||||
"Agent updates from SAC are disabled (mode=gpo). Use GPO/manual update or switch to mode=sac."
|
||||
)
|
||||
if not host.agent_instance_id:
|
||||
raise AgentUpdateNotAllowedError("Host has no agent_instance_id (agent never connected via SAC)")
|
||||
|
||||
target = resolve_target_version(db, host, cfg)
|
||||
now = datetime.now(timezone.utc)
|
||||
host.pending_agent_update = True
|
||||
host.pending_update_requested_at = now
|
||||
host.pending_update_target_version = target or None
|
||||
host.agent_update_state = "requested"
|
||||
host.agent_update_last_error = None
|
||||
db.flush()
|
||||
return host
|
||||
|
||||
|
||||
def _clear_pending(host: Host) -> None:
|
||||
host.pending_agent_update = False
|
||||
host.pending_update_requested_at = None
|
||||
host.pending_update_target_version = None
|
||||
|
||||
|
||||
def process_agent_update_ingest(db: Session, host: Host, event_type: str, details: dict | None) -> None:
|
||||
if event_type not in AGENT_UPDATE_TYPES:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
host.agent_update_last_at = now
|
||||
details = details if isinstance(details, dict) else {}
|
||||
|
||||
if event_type == AGENT_UPDATE_STARTED:
|
||||
host.agent_update_state = "started"
|
||||
return
|
||||
|
||||
if event_type == AGENT_UPDATE_SUCCESS:
|
||||
host.agent_update_state = "success"
|
||||
host.agent_update_last_error = None
|
||||
_clear_pending(host)
|
||||
version = details.get("product_version") or details.get("version")
|
||||
if isinstance(version, str) and version.strip():
|
||||
host.product_version = version.strip()
|
||||
return
|
||||
|
||||
if event_type == AGENT_UPDATE_FAILED:
|
||||
host.agent_update_state = "failed"
|
||||
err = details.get("error") or details.get("message")
|
||||
host.agent_update_last_error = str(err)[:2000] if err else "agent.update.failed"
|
||||
_clear_pending(host)
|
||||
|
||||
|
||||
def run_linux_agent_update_fallback(
|
||||
host: Host,
|
||||
cfg_linux,
|
||||
*,
|
||||
repo_url: str | None = None,
|
||||
git_branch: str | None = None,
|
||||
) -> AgentUpdateFallbackResult:
|
||||
if not is_linux_host(host):
|
||||
return AgentUpdateFallbackResult(ok=False, message="Host is not Linux", product_version=None)
|
||||
if not cfg_linux.configured:
|
||||
return AgentUpdateFallbackResult(
|
||||
ok=False,
|
||||
message="Linux SSH admin is not configured",
|
||||
product_version=None,
|
||||
)
|
||||
|
||||
last = AgentUpdateFallbackResult(ok=False, message="SSH fallback failed", product_version=None)
|
||||
try:
|
||||
targets = iter_ssh_targets(host)
|
||||
except (HostNotLinuxError, HostTargetMissingError) as exc:
|
||||
return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None)
|
||||
|
||||
effective_repo = (repo_url or "").strip() or None
|
||||
effective_branch = (git_branch or "main").strip() or "main"
|
||||
for target in targets:
|
||||
result = run_ssh_monitor_update(
|
||||
target=target,
|
||||
user=cfg_linux.user,
|
||||
password=cfg_linux.password,
|
||||
repo_url=effective_repo,
|
||||
git_branch=effective_branch,
|
||||
)
|
||||
last = AgentUpdateFallbackResult(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
product_version=result.agent_version,
|
||||
target=result.target,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
exit_code=result.exit_code,
|
||||
)
|
||||
if result.ok:
|
||||
return last
|
||||
return last
|
||||
|
||||
|
||||
def run_windows_agent_update_fallback(
|
||||
host: Host,
|
||||
cfg_win,
|
||||
script_path: str,
|
||||
*,
|
||||
repo_url: str = "",
|
||||
git_branch: str = "main",
|
||||
) -> AgentUpdateFallbackResult:
|
||||
if not is_windows_host(host):
|
||||
return AgentUpdateFallbackResult(ok=False, message="Host is not Windows", product_version=None)
|
||||
if not cfg_win.configured:
|
||||
return AgentUpdateFallbackResult(
|
||||
ok=False,
|
||||
message="Windows domain admin is not configured",
|
||||
product_version=None,
|
||||
)
|
||||
|
||||
try:
|
||||
targets = iter_winrm_targets(host)
|
||||
except (HostNotWindowsError, WinRmHostTargetMissingError) as exc:
|
||||
return AgentUpdateFallbackResult(ok=False, message=str(exc), product_version=None)
|
||||
|
||||
result, attempts = run_winrm_on_host_targets(
|
||||
host,
|
||||
user=cfg_win.user,
|
||||
password=cfg_win.password,
|
||||
action=lambda target: run_winrm_rdp_monitor_update(
|
||||
target=target,
|
||||
user=cfg_win.user,
|
||||
password=cfg_win.password,
|
||||
script_path=script_path,
|
||||
repo_url=repo_url,
|
||||
git_branch=git_branch,
|
||||
),
|
||||
)
|
||||
if result is None:
|
||||
return AgentUpdateFallbackResult(ok=False, message="WinRM fallback failed", product_version=None)
|
||||
message = result.message
|
||||
if attempts:
|
||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||
version = None
|
||||
if result.ok and result.stdout:
|
||||
from app.services.ssh_connect import parse_ssh_monitor_version_text
|
||||
|
||||
version = parse_ssh_monitor_version_text(result.stdout)
|
||||
return AgentUpdateFallbackResult(
|
||||
ok=result.ok,
|
||||
message=message,
|
||||
product_version=version,
|
||||
target=result.target,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
exit_code=result.exit_code,
|
||||
)
|
||||
|
||||
|
||||
def execute_agent_update_fallback(db: Session, host: Host) -> AgentUpdateFallbackResult:
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
product = (host.product or "").strip()
|
||||
|
||||
if product == PRODUCT_SSH or is_linux_host(host):
|
||||
linux_cfg = get_effective_linux_admin_for_host(db, host)
|
||||
agent_cfg = get_effective_agent_update_config(db)
|
||||
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
||||
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
||||
result = run_linux_agent_update_fallback(
|
||||
host,
|
||||
linux_cfg,
|
||||
repo_url=repo_url,
|
||||
git_branch=branch,
|
||||
)
|
||||
elif product == PRODUCT_RDP or is_windows_host(host):
|
||||
win_cfg = get_effective_win_admin_for_host(db, host)
|
||||
result = run_windows_agent_update_fallback(
|
||||
host,
|
||||
win_cfg,
|
||||
cfg.win_agent_update_script,
|
||||
repo_url=cfg.rdp_git_repo_url,
|
||||
git_branch=cfg.git_branch,
|
||||
)
|
||||
else:
|
||||
return AgentUpdateFallbackResult(
|
||||
ok=False,
|
||||
message=f"Unsupported product for fallback update: {product}",
|
||||
product_version=None,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
host.agent_update_last_at = now
|
||||
if result.ok:
|
||||
host.agent_update_state = "success"
|
||||
host.agent_update_last_error = None
|
||||
_clear_pending(host)
|
||||
if result.product_version:
|
||||
host.product_version = result.product_version
|
||||
if is_linux_host(host):
|
||||
host.ssh_admin_ok = True
|
||||
host.ssh_admin_checked_at = now
|
||||
else:
|
||||
host.agent_update_state = "failed"
|
||||
host.agent_update_last_error = result.message[:2000]
|
||||
|
||||
db.flush()
|
||||
return result
|
||||
|
||||
|
||||
def process_agent_update_fallbacks(db: Session) -> list[dict]:
|
||||
"""Find stale pending updates and run SSH/WinRM fallback when enabled."""
|
||||
cfg = get_effective_agent_update_config(db)
|
||||
if not cfg.sac_managed or not cfg.fallback_enabled:
|
||||
return []
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=cfg.fallback_after_minutes)
|
||||
rows = db.scalars(
|
||||
select(Host).where(
|
||||
Host.pending_agent_update.is_(True),
|
||||
Host.pending_update_requested_at.isnot(None),
|
||||
Host.pending_update_requested_at <= cutoff,
|
||||
Host.agent_update_state.in_(("requested", "started")),
|
||||
)
|
||||
).all()
|
||||
|
||||
results: list[dict] = []
|
||||
for host in rows:
|
||||
result = execute_agent_update_fallback(db, host)
|
||||
results.append(
|
||||
{
|
||||
"host_id": host.id,
|
||||
"hostname": host.hostname,
|
||||
"ok": result.ok,
|
||||
"message": result.message,
|
||||
"product_version": result.product_version,
|
||||
}
|
||||
)
|
||||
return results
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Global agent update policy (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
|
||||
|
||||
PRODUCT_RDP = "rdp-login-monitor"
|
||||
PRODUCT_SSH = "ssh-monitor"
|
||||
AGENT_UPDATE_MODES = frozenset({"gpo", "sac"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentUpdateConfig:
|
||||
mode: str
|
||||
fallback_enabled: bool
|
||||
fallback_after_minutes: int
|
||||
recommended_rdp_version: str
|
||||
recommended_ssh_version: str
|
||||
min_rdp_version: str
|
||||
min_ssh_version: str
|
||||
win_agent_update_script: str
|
||||
rdp_git_repo_url: str
|
||||
ssh_git_repo_url: str
|
||||
git_branch: str
|
||||
source: str # env | db
|
||||
|
||||
@property
|
||||
def sac_managed(self) -> bool:
|
||||
return self.mode == "sac"
|
||||
|
||||
def recommended_for_product(self, product: str) -> str:
|
||||
if product == PRODUCT_RDP:
|
||||
return self.recommended_rdp_version
|
||||
if product == PRODUCT_SSH:
|
||||
return self.recommended_ssh_version
|
||||
return ""
|
||||
|
||||
def min_for_product(self, product: str) -> str:
|
||||
if product == PRODUCT_RDP:
|
||||
return self.min_rdp_version
|
||||
if product == PRODUCT_SSH:
|
||||
return self.min_ssh_version
|
||||
return ""
|
||||
|
||||
|
||||
def _agent_update_from_env() -> AgentUpdateConfig:
|
||||
settings = get_settings()
|
||||
mode = (settings.sac_agent_update_mode or "gpo").strip().lower()
|
||||
if mode not in AGENT_UPDATE_MODES:
|
||||
mode = "gpo"
|
||||
return AgentUpdateConfig(
|
||||
mode=mode,
|
||||
fallback_enabled=settings.sac_agent_update_fallback_enabled,
|
||||
fallback_after_minutes=max(1, int(settings.sac_agent_update_fallback_minutes)),
|
||||
recommended_rdp_version=(settings.sac_agent_recommended_rdp_version or "").strip(),
|
||||
recommended_ssh_version=(settings.sac_agent_recommended_ssh_version or "").strip(),
|
||||
min_rdp_version=(settings.sac_agent_min_rdp_version or "").strip(),
|
||||
min_ssh_version=(settings.sac_agent_min_ssh_version or "").strip(),
|
||||
win_agent_update_script=(settings.sac_win_agent_update_script or "").strip(),
|
||||
rdp_git_repo_url=(settings.sac_agent_rdp_git_repo_url or "").strip(),
|
||||
ssh_git_repo_url=(settings.sac_agent_ssh_git_repo_url or "").strip(),
|
||||
git_branch=(settings.sac_agent_git_branch or "main").strip() or "main",
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def _row_has_agent_update_values(row: UiSettings) -> bool:
|
||||
return any(
|
||||
[
|
||||
(row.agent_update_mode or "").strip() not in ("", "gpo"),
|
||||
row.agent_update_fallback_enabled is not None,
|
||||
row.agent_update_fallback_minutes not in (None, 15),
|
||||
bool((row.agent_recommended_rdp_version or "").strip()),
|
||||
bool((row.agent_recommended_ssh_version or "").strip()),
|
||||
bool((row.agent_min_rdp_version or "").strip()),
|
||||
bool((row.agent_min_ssh_version or "").strip()),
|
||||
bool((row.win_agent_update_script or "").strip()),
|
||||
bool((row.agent_rdp_git_repo_url or "").strip()),
|
||||
bool((row.agent_ssh_git_repo_url or "").strip()),
|
||||
(row.agent_git_branch or "main").strip() not in ("", "main"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_effective_agent_update_config(db: Session | None = None) -> AgentUpdateConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_agent_update_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_cfg = _agent_update_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
mode = (row.agent_update_mode or env_cfg.mode).strip().lower()
|
||||
if mode not in AGENT_UPDATE_MODES:
|
||||
mode = env_cfg.mode
|
||||
|
||||
return AgentUpdateConfig(
|
||||
mode=mode,
|
||||
fallback_enabled=(
|
||||
row.agent_update_fallback_enabled
|
||||
if row.agent_update_fallback_enabled is not None
|
||||
else env_cfg.fallback_enabled
|
||||
),
|
||||
fallback_after_minutes=max(
|
||||
1,
|
||||
int(row.agent_update_fallback_minutes or env_cfg.fallback_after_minutes),
|
||||
),
|
||||
recommended_rdp_version=(row.agent_recommended_rdp_version or "").strip()
|
||||
or env_cfg.recommended_rdp_version,
|
||||
recommended_ssh_version=(row.agent_recommended_ssh_version or "").strip()
|
||||
or env_cfg.recommended_ssh_version,
|
||||
min_rdp_version=(row.agent_min_rdp_version or "").strip() or env_cfg.min_rdp_version,
|
||||
min_ssh_version=(row.agent_min_ssh_version or "").strip() or env_cfg.min_ssh_version,
|
||||
win_agent_update_script=(row.win_agent_update_script or "").strip()
|
||||
or env_cfg.win_agent_update_script,
|
||||
rdp_git_repo_url=(row.agent_rdp_git_repo_url or "").strip() or env_cfg.rdp_git_repo_url,
|
||||
ssh_git_repo_url=(row.agent_ssh_git_repo_url or "").strip() or env_cfg.ssh_git_repo_url,
|
||||
git_branch=(row.agent_git_branch or env_cfg.git_branch).strip() or env_cfg.git_branch,
|
||||
source="db" if _row_has_agent_update_values(row) else env_cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def upsert_agent_update_settings(
|
||||
db: Session,
|
||||
*,
|
||||
mode: str | None = None,
|
||||
fallback_enabled: bool | None = None,
|
||||
fallback_after_minutes: int | None = None,
|
||||
recommended_rdp_version: str | None = None,
|
||||
recommended_ssh_version: str | None = None,
|
||||
min_rdp_version: str | None = None,
|
||||
min_ssh_version: str | None = None,
|
||||
win_agent_update_script: str | None = None,
|
||||
rdp_git_repo_url: str | None = None,
|
||||
ssh_git_repo_url: str | None = None,
|
||||
git_branch: str | None = None,
|
||||
) -> AgentUpdateConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
|
||||
db.add(row)
|
||||
|
||||
if mode is not None:
|
||||
normalized = mode.strip().lower()
|
||||
if normalized not in AGENT_UPDATE_MODES:
|
||||
raise ValueError(f"mode must be one of: {sorted(AGENT_UPDATE_MODES)}")
|
||||
row.agent_update_mode = normalized
|
||||
if fallback_enabled is not None:
|
||||
row.agent_update_fallback_enabled = fallback_enabled
|
||||
if fallback_after_minutes is not None:
|
||||
row.agent_update_fallback_minutes = max(1, int(fallback_after_minutes))
|
||||
if recommended_rdp_version is not None:
|
||||
row.agent_recommended_rdp_version = recommended_rdp_version.strip() or None
|
||||
if recommended_ssh_version is not None:
|
||||
row.agent_recommended_ssh_version = recommended_ssh_version.strip() or None
|
||||
if min_rdp_version is not None:
|
||||
row.agent_min_rdp_version = min_rdp_version.strip() or None
|
||||
if min_ssh_version is not None:
|
||||
row.agent_min_ssh_version = min_ssh_version.strip() or None
|
||||
if win_agent_update_script is not None:
|
||||
row.win_agent_update_script = win_agent_update_script.strip() or None
|
||||
if rdp_git_repo_url is not None:
|
||||
row.agent_rdp_git_repo_url = rdp_git_repo_url.strip() or None
|
||||
row.agent_git_rdp_version = None
|
||||
row.agent_git_fetched_at = None
|
||||
if ssh_git_repo_url is not None:
|
||||
row.agent_ssh_git_repo_url = ssh_git_repo_url.strip() or None
|
||||
row.agent_git_ssh_version = None
|
||||
row.agent_git_fetched_at = None
|
||||
if git_branch is not None:
|
||||
branch = git_branch.strip() or "main"
|
||||
if branch != (row.agent_git_branch or "main"):
|
||||
row.agent_git_rdp_version = None
|
||||
row.agent_git_ssh_version = None
|
||||
row.agent_git_fetched_at = None
|
||||
row.agent_git_branch = branch
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_agent_update_config(db)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Agent update fallback result with remote command streams."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentUpdateFallbackResult:
|
||||
ok: bool
|
||||
message: str
|
||||
product_version: str | None
|
||||
target: str = ""
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int | None = None
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Сравнение версий агентов (ssh-monitor / rdp-login-monitor, формат X.Y.Z-SAC)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Host
|
||||
|
||||
_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-SAC)?$", re.IGNORECASE)
|
||||
_DEFAULT_LAG_THRESHOLD = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class ParsedAgentVersion:
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
|
||||
|
||||
def parse_agent_version(raw: str | None) -> ParsedAgentVersion | None:
|
||||
if not raw:
|
||||
return None
|
||||
text = raw.strip()
|
||||
match = _VERSION_RE.match(text)
|
||||
if not match:
|
||||
return None
|
||||
return ParsedAgentVersion(
|
||||
major=int(match.group(1)),
|
||||
minor=int(match.group(2)),
|
||||
patch=int(match.group(3)),
|
||||
)
|
||||
|
||||
|
||||
def agent_version_lag(host: ParsedAgentVersion, latest: ParsedAgentVersion) -> int | None:
|
||||
"""Отставание от latest: patch при том же major.minor, иначе None (любое отставание)."""
|
||||
if host > latest:
|
||||
return 0
|
||||
if host.major != latest.major or host.minor != latest.minor:
|
||||
return None
|
||||
return latest.patch - host.patch
|
||||
|
||||
|
||||
def is_agent_version_outdated(
|
||||
host_version: str | None,
|
||||
latest_version: str | None,
|
||||
*,
|
||||
lag_threshold: int = _DEFAULT_LAG_THRESHOLD,
|
||||
) -> bool:
|
||||
host = parse_agent_version(host_version)
|
||||
latest = parse_agent_version(latest_version)
|
||||
if host is None or latest is None or host >= latest:
|
||||
return False
|
||||
lag = agent_version_lag(host, latest)
|
||||
if lag is None:
|
||||
return True
|
||||
return lag >= lag_threshold
|
||||
|
||||
|
||||
def effective_reference_version(
|
||||
*,
|
||||
recommended: str = "",
|
||||
fleet_latest: str = "",
|
||||
min_version: str = "",
|
||||
) -> str | None:
|
||||
"""Единый эталон: max(recommended, min_version, max версия в fleet по продукту)."""
|
||||
best: ParsedAgentVersion | None = None
|
||||
best_raw: str | None = None
|
||||
for raw in (recommended, min_version, fleet_latest):
|
||||
text = (raw or "").strip()
|
||||
parsed = parse_agent_version(text)
|
||||
if parsed is None:
|
||||
continue
|
||||
if best is None or parsed > best:
|
||||
best = parsed
|
||||
best_raw = text
|
||||
return best_raw
|
||||
|
||||
|
||||
def reference_agent_versions_by_product(
|
||||
cfg,
|
||||
latest_map: dict[str, str],
|
||||
*,
|
||||
git_versions: dict[str, str] | None = None,
|
||||
products: tuple[str, ...] = ("rdp-login-monitor", "ssh-monitor"),
|
||||
) -> dict[str, str]:
|
||||
from app.services.agent_git_release import recommended_from_git
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
|
||||
git_versions = git_versions or {}
|
||||
product_cfg = {
|
||||
PRODUCT_RDP: (recommended_from_git(cfg, git_versions, PRODUCT_RDP), cfg.min_for_product(PRODUCT_RDP)),
|
||||
PRODUCT_SSH: (recommended_from_git(cfg, git_versions, PRODUCT_SSH), cfg.min_for_product(PRODUCT_SSH)),
|
||||
}
|
||||
out: dict[str, str] = {}
|
||||
for product in products:
|
||||
recommended, min_version = product_cfg.get(product, ("", ""))
|
||||
ref = effective_reference_version(
|
||||
recommended=recommended,
|
||||
fleet_latest=latest_map.get(product, ""),
|
||||
min_version=min_version,
|
||||
)
|
||||
if ref:
|
||||
out[product] = ref
|
||||
return out
|
||||
|
||||
|
||||
def host_version_outdated(
|
||||
host_version: str | None,
|
||||
*,
|
||||
recommended: str = "",
|
||||
fleet_latest: str = "",
|
||||
min_version: str = "",
|
||||
lag_threshold: int = _DEFAULT_LAG_THRESHOLD,
|
||||
) -> bool | None:
|
||||
"""True=outdated, False=ok, None=unknown (нет эталона или непарсится версия хоста)."""
|
||||
if not host_version or parse_agent_version(host_version) is None:
|
||||
return None
|
||||
reference = effective_reference_version(
|
||||
recommended=recommended,
|
||||
fleet_latest=fleet_latest,
|
||||
min_version=min_version,
|
||||
)
|
||||
if not reference:
|
||||
return None
|
||||
return is_agent_version_outdated(host_version, reference, lag_threshold=lag_threshold)
|
||||
|
||||
|
||||
def latest_agent_versions_by_product(db: Session) -> dict[str, str]:
|
||||
rows = db.execute(
|
||||
select(Host.product, Host.product_version).where(
|
||||
Host.product_version.isnot(None),
|
||||
Host.product_version != "",
|
||||
)
|
||||
).all()
|
||||
best: dict[str, ParsedAgentVersion] = {}
|
||||
best_raw: dict[str, str] = {}
|
||||
for product, version in rows:
|
||||
parsed = parse_agent_version(version)
|
||||
if parsed is None:
|
||||
continue
|
||||
current = best.get(product)
|
||||
if current is None or parsed > current:
|
||||
best[product] = parsed
|
||||
best_raw[product] = version.strip()
|
||||
return best_raw
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Resolve client IP behind reverse proxy (nginx $proxy_add_x_forwarded_for)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
def client_ip_from_request(request: Request) -> str:
|
||||
"""Client IP for rate limiting.
|
||||
|
||||
nginx with ``proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for``
|
||||
appends the real peer address as the *last* hop. Spoofed values in the
|
||||
incoming header therefore cannot replace the actual client IP.
|
||||
"""
|
||||
if request.client and request.client.host:
|
||||
direct = request.client.host.strip()
|
||||
else:
|
||||
direct = "unknown"
|
||||
|
||||
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
|
||||
if not forwarded:
|
||||
return direct[:64]
|
||||
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if not parts:
|
||||
return direct[:64]
|
||||
return parts[-1][:64]
|
||||
@@ -0,0 +1,368 @@
|
||||
"""SAC-generated daily reports from ingested events (F-NOT-05 / notif-32)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import Event, Host
|
||||
from app.services.daily_report_format import (
|
||||
RDP_BAN_TYPES,
|
||||
SSH_BAN_TYPES,
|
||||
body_to_report_html,
|
||||
build_report_body,
|
||||
enrich_stats_for_storage,
|
||||
normalize_daily_report_details,
|
||||
)
|
||||
from app.services.ingest import ingest_event
|
||||
from app.services.notify_dispatch import notify_daily_report
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPORT_TYPES = {
|
||||
"ssh-monitor": "report.daily.ssh",
|
||||
"rdp-login-monitor": "report.daily.rdp",
|
||||
}
|
||||
|
||||
SSH_SUCCESS = "ssh.login.success"
|
||||
SSH_FAILED = "ssh.login.failed"
|
||||
SSH_SUDO = "privilege.sudo.command"
|
||||
RDP_SUCCESS = "rdp.login.success"
|
||||
RDP_FAILED = "rdp.login.failed"
|
||||
SESSION_LOGIND_NEW = "session.logind.new"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DailyReportResult:
|
||||
host_id: int
|
||||
hostname: str
|
||||
report_type: str
|
||||
event_id: str
|
||||
created: bool
|
||||
skipped_reason: str | None = None
|
||||
|
||||
|
||||
def _now_in_tz(settings: Settings) -> datetime:
|
||||
tz = ZoneInfo(settings.sac_daily_report_timezone.strip() or "Europe/Moscow")
|
||||
return datetime.now(timezone.utc).astimezone(tz)
|
||||
|
||||
|
||||
def _report_type_for_host(host: Host) -> str | None:
|
||||
return REPORT_TYPES.get((host.product or "").strip())
|
||||
|
||||
|
||||
def _day_start_in_tz(settings: Settings, ref: datetime | None = None) -> datetime:
|
||||
local = ref or _now_in_tz(settings)
|
||||
start = local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return start.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def host_has_report_today(db: Session, host_id: int, report_type: str, settings: Settings) -> bool:
|
||||
since = _day_start_in_tz(settings)
|
||||
row = db.scalar(
|
||||
select(Event.id)
|
||||
.where(
|
||||
Event.host_id == host_id,
|
||||
Event.type == report_type,
|
||||
Event.received_at >= since,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
def _ip_from_details(details: dict[str, Any] | None) -> str:
|
||||
if not details:
|
||||
return ""
|
||||
for key in ("source_ip", "ip_address", "ip"):
|
||||
val = details.get(key)
|
||||
if val is not None and str(val).strip() not in ("", "-"):
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _user_from_details(details: dict[str, Any] | None) -> str:
|
||||
if not details:
|
||||
return ""
|
||||
for key in ("user", "username"):
|
||||
val = details.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _format_user_line_ssh(details: dict[str, Any] | None) -> str | None:
|
||||
user = _user_from_details(details)
|
||||
if not user:
|
||||
return None
|
||||
if not details:
|
||||
return f"👤 {user}"
|
||||
tty = str(details.get("tty") or details.get("pts") or "").strip()
|
||||
since = str(details.get("since") or details.get("session_since") or "").strip()
|
||||
ip = _ip_from_details(details)
|
||||
parts = [f"👤 {user}"]
|
||||
if tty:
|
||||
parts.append(f"| {tty}")
|
||||
if since:
|
||||
parts.append(f"| с {since}")
|
||||
if ip:
|
||||
parts.append(f"| 🌐 {ip}")
|
||||
return " ".join(parts) if len(parts) > 1 else f"👤 {user}"
|
||||
|
||||
|
||||
def _collect_active_users_ssh(events: list[Event]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for ev in events:
|
||||
if ev.type != SESSION_LOGIND_NEW:
|
||||
continue
|
||||
details = ev.details if isinstance(ev.details, dict) else None
|
||||
line = _format_user_line_ssh(details)
|
||||
if line and line not in seen:
|
||||
lines.append(line)
|
||||
seen.add(line)
|
||||
if lines:
|
||||
return lines
|
||||
for ev in events:
|
||||
if ev.type != SSH_SUCCESS:
|
||||
continue
|
||||
details = ev.details if isinstance(ev.details, dict) else None
|
||||
user = _user_from_details(details)
|
||||
if not user:
|
||||
continue
|
||||
key = user.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ip = _ip_from_details(details)
|
||||
line = f"👤 {user}"
|
||||
if ip:
|
||||
line += f" | 🌐 {ip}"
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def _collect_active_users_rdp(events: list[Event]) -> list[str]:
|
||||
users: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for ev in events:
|
||||
if ev.type != RDP_SUCCESS:
|
||||
continue
|
||||
details = ev.details if isinstance(ev.details, dict) else None
|
||||
user = _user_from_details(details)
|
||||
if not user:
|
||||
continue
|
||||
key = user.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
users.append(f"👤 {user}")
|
||||
return users
|
||||
|
||||
|
||||
def _aggregate_ssh(events: list[Event]) -> dict[str, Any]:
|
||||
ok = failed = sudo = 0
|
||||
failed_ips: Counter[str] = Counter()
|
||||
for ev in events:
|
||||
if ev.type == SSH_SUCCESS:
|
||||
ok += 1
|
||||
elif ev.type == SSH_FAILED:
|
||||
failed += 1
|
||||
ip = _ip_from_details(ev.details if isinstance(ev.details, dict) else None)
|
||||
if ip:
|
||||
failed_ips[ip] += 1
|
||||
elif ev.type == SSH_SUDO:
|
||||
sudo += 1
|
||||
top_ips = [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)]
|
||||
active_users = _collect_active_users_ssh(events)
|
||||
return {
|
||||
"successful_ssh": ok,
|
||||
"failed_ssh": failed,
|
||||
"sudo_commands": sudo,
|
||||
"active_bans": sum(1 for e in events if e.type in SSH_BAN_TYPES),
|
||||
"top_failed_ips": top_ips,
|
||||
"active_users": active_users,
|
||||
}
|
||||
|
||||
|
||||
def _aggregate_rdp(events: list[Event]) -> dict[str, Any]:
|
||||
ok = failed = 0
|
||||
failed_ips: Counter[str] = Counter()
|
||||
for ev in events:
|
||||
details = ev.details if isinstance(ev.details, dict) else None
|
||||
if ev.type == RDP_SUCCESS:
|
||||
ok += 1
|
||||
elif ev.type == RDP_FAILED:
|
||||
failed += 1
|
||||
ip = _ip_from_details(details)
|
||||
if ip:
|
||||
failed_ips[ip] += 1
|
||||
active_users = _collect_active_users_rdp(events)
|
||||
unique = []
|
||||
seen: set[str] = set()
|
||||
for line in active_users:
|
||||
u = line.replace("👤", "").strip().split("|")[0].strip()
|
||||
if u.lower() not in seen:
|
||||
seen.add(u.lower())
|
||||
unique.append(u)
|
||||
return {
|
||||
"rdp_success": ok,
|
||||
"rdp_failed": failed,
|
||||
"active_bans": sum(1 for e in events if e.type in RDP_BAN_TYPES),
|
||||
"top_failed_ips": [f"{ip} — {cnt}" for ip, cnt in failed_ips.most_common(5)],
|
||||
"active_users": active_users,
|
||||
"unique_users": unique,
|
||||
}
|
||||
|
||||
|
||||
def _build_report_body_ssh(host: Host, stats: dict[str, Any], when_local: datetime) -> str:
|
||||
enriched = enrich_stats_for_storage("ssh", stats)
|
||||
return build_report_body("ssh", host, enriched, when_local, sac_generated=True)
|
||||
|
||||
|
||||
def _build_report_body_rdp(host: Host, stats: dict[str, Any], when_local: datetime) -> str:
|
||||
enriched = enrich_stats_for_storage("windows", stats)
|
||||
return build_report_body("windows", host, enriched, when_local, sac_generated=True)
|
||||
|
||||
|
||||
def _details_from_body(body: str, stats: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"stats": stats,
|
||||
"report_body": body,
|
||||
"report_format": "plain",
|
||||
"report_html": body_to_report_html(body),
|
||||
"generated_by": "sac",
|
||||
}
|
||||
|
||||
|
||||
def _events_last_24h(db: Session, host_id: int) -> list[Event]:
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Event)
|
||||
.where(Event.host_id == host_id, Event.occurred_at >= since)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def generate_daily_report_for_host(db: Session, host: Host, settings: Settings | None = None) -> DailyReportResult | None:
|
||||
cfg = settings or get_settings()
|
||||
report_type = _report_type_for_host(host)
|
||||
if report_type is None:
|
||||
return None
|
||||
|
||||
if cfg.sac_daily_report_skip_if_agent_sent and host_has_report_today(db, host.id, report_type, cfg):
|
||||
return DailyReportResult(
|
||||
host_id=host.id,
|
||||
hostname=host.hostname,
|
||||
report_type=report_type,
|
||||
event_id="",
|
||||
created=False,
|
||||
skipped_reason="agent_report_exists_today",
|
||||
)
|
||||
|
||||
events = _events_last_24h(db, host.id)
|
||||
if not events and cfg.sac_daily_report_require_activity:
|
||||
return DailyReportResult(
|
||||
host_id=host.id,
|
||||
hostname=host.hostname,
|
||||
report_type=report_type,
|
||||
event_id="",
|
||||
created=False,
|
||||
skipped_reason="no_events_24h",
|
||||
)
|
||||
|
||||
when_local = _now_in_tz(cfg)
|
||||
if report_type == "report.daily.ssh":
|
||||
stats = enrich_stats_for_storage("ssh", _aggregate_ssh(events))
|
||||
body = _build_report_body_ssh(host, stats, when_local)
|
||||
title = "Ежедневный отчёт SSH"
|
||||
summary = (
|
||||
f"SSH 24ч: успех {stats['successful_logins']}, неудач {stats['failed_logins']}, "
|
||||
f"sudo {stats['sudo_commands']}"
|
||||
)
|
||||
else:
|
||||
stats = enrich_stats_for_storage("windows", _aggregate_rdp(events))
|
||||
body = _build_report_body_rdp(host, stats, when_local)
|
||||
title = "Ежедневный отчёт Windows"
|
||||
summary = (
|
||||
f"RDP 24ч: успех {stats['successful_logins']}, неудач {stats['failed_logins']}, "
|
||||
f"банов {stats['active_bans']}"
|
||||
)
|
||||
|
||||
event_id = str(uuid.uuid4())
|
||||
day_key = when_local.strftime("%Y-%m-%d")
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"event_id": event_id,
|
||||
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source": {
|
||||
"product": host.product or "unknown",
|
||||
"product_version": host.product_version or "sac",
|
||||
},
|
||||
"host": {
|
||||
"hostname": host.hostname,
|
||||
"os_family": host.os_family or ("windows" if report_type == "report.daily.rdp" else "linux"),
|
||||
"display_name": host.display_name,
|
||||
"ipv4": host.ipv4,
|
||||
},
|
||||
"category": "report",
|
||||
"type": report_type,
|
||||
"severity": "info",
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"details": _details_from_body(body, stats),
|
||||
"dedup_key": f"sac|{host.id}|{report_type}|{day_key}",
|
||||
}
|
||||
|
||||
event, created = ingest_event(db, payload)
|
||||
if created:
|
||||
notify_daily_report(event, db=db)
|
||||
return DailyReportResult(
|
||||
host_id=host.id,
|
||||
hostname=host.hostname,
|
||||
report_type=report_type,
|
||||
event_id=event.event_id,
|
||||
created=created,
|
||||
skipped_reason=None if created else "duplicate_event_id",
|
||||
)
|
||||
|
||||
|
||||
def run_daily_reports(db: Session, settings: Settings | None = None, *, force: bool = False) -> list[DailyReportResult]:
|
||||
cfg = settings or get_settings()
|
||||
if not cfg.sac_daily_report_enabled:
|
||||
logger.info("daily report disabled (SAC_DAILY_REPORT_ENABLED=false)")
|
||||
return []
|
||||
|
||||
local_now = _now_in_tz(cfg)
|
||||
if not force and local_now.hour < cfg.sac_daily_report_hour:
|
||||
logger.info(
|
||||
"daily report skipped: local hour %s < SAC_DAILY_REPORT_HOUR=%s",
|
||||
local_now.hour,
|
||||
cfg.sac_daily_report_hour,
|
||||
)
|
||||
return []
|
||||
|
||||
hosts = db.scalars(select(Host).order_by(Host.hostname)).all()
|
||||
results: list[DailyReportResult] = []
|
||||
for host in hosts:
|
||||
try:
|
||||
res = generate_daily_report_for_host(db, host, cfg)
|
||||
if res is not None:
|
||||
results.append(res)
|
||||
except Exception:
|
||||
logger.exception("daily report failed host_id=%s hostname=%s", host.id, host.hostname)
|
||||
db.commit()
|
||||
created_n = sum(1 for r in results if r.created)
|
||||
logger.info("daily report finished hosts=%s created=%s", len(results), created_n)
|
||||
return results
|
||||
@@ -0,0 +1,492 @@
|
||||
"""Единый текст и stats для report.daily.ssh / report.daily.rdp (агент и SAC)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.models import Host
|
||||
|
||||
Platform = Literal["ssh", "windows"]
|
||||
|
||||
RDP_BAN_TYPES = frozenset({"rdp.ip.banned", "rdp.ip.ban"})
|
||||
SSH_BAN_TYPES = frozenset({"ssh.ip.banned"})
|
||||
|
||||
_SERVER_LINE_RE = re.compile(r"(?m)^🖥️\s*Сервер\s*:")
|
||||
_ACTIVE_USERS_HEADER_RE = re.compile(r"^👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ")
|
||||
_ACTIVE_USERS_EMPTY_LINE_RE = re.compile(
|
||||
r"^\(?нет данных|нет активных пользователей",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_AGENT_VERSION_LINE_RE = re.compile(r"(?m)^Agent version\s+", re.IGNORECASE)
|
||||
_NOTIFICATION_SOURCE_RE = re.compile(r"(?m)^📡\s*Оповещение:\s*")
|
||||
_LEGACY_SAC_SOURCE_RE = re.compile(
|
||||
r"(?m)^Источник:\s*Security Alert Center\b.*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DAILY_REPORT_TITLE_MARKER = "ЕЖЕДНЕВНЫЙ ОТЧЕТ"
|
||||
_SECTION_HEADER_RE = re.compile(r"^[📈🧾👥🖥️🕐]")
|
||||
NOTIFICATION_SOURCE_PREFIX = "📡 Оповещение:"
|
||||
|
||||
|
||||
def format_notification_source_plain(
|
||||
*,
|
||||
generated_by: str,
|
||||
product: str | None = None,
|
||||
product_version: str | None = None,
|
||||
) -> str:
|
||||
gb = (generated_by or "agent").strip().lower()
|
||||
if gb == "sac":
|
||||
return f"{NOTIFICATION_SOURCE_PREFIX} SAC (Security Alert Center)"
|
||||
label = (product or "агент").strip()
|
||||
version = (product_version or "").strip()
|
||||
if version:
|
||||
return f"{NOTIFICATION_SOURCE_PREFIX} агент ({label} {version})"
|
||||
return f"{NOTIFICATION_SOURCE_PREFIX} агент ({label})"
|
||||
|
||||
|
||||
def has_notification_source_line(text: str) -> bool:
|
||||
return bool(_NOTIFICATION_SOURCE_RE.search(text or ""))
|
||||
|
||||
|
||||
def strip_legacy_sac_source_line(text: str) -> str:
|
||||
return _LEGACY_SAC_SOURCE_RE.sub("", text or "").strip()
|
||||
|
||||
|
||||
def append_notification_source_plain(
|
||||
body: str,
|
||||
*,
|
||||
generated_by: str,
|
||||
product: str | None = None,
|
||||
product_version: str | None = None,
|
||||
) -> str:
|
||||
text = strip_legacy_sac_source_line(body or "")
|
||||
if has_notification_source_line(text):
|
||||
return text
|
||||
line = format_notification_source_plain(
|
||||
generated_by=generated_by,
|
||||
product=product,
|
||||
product_version=product_version,
|
||||
)
|
||||
if text:
|
||||
return f"{text.rstrip()}\n\n{line}"
|
||||
return line
|
||||
|
||||
|
||||
def append_notification_source_html(
|
||||
html_msg: str,
|
||||
*,
|
||||
generated_by: str,
|
||||
product: str | None = None,
|
||||
product_version: str | None = None,
|
||||
) -> str:
|
||||
plain_line = format_notification_source_plain(
|
||||
generated_by=generated_by,
|
||||
product=product,
|
||||
product_version=product_version,
|
||||
)
|
||||
if NOTIFICATION_SOURCE_PREFIX in (html_msg or ""):
|
||||
return (html_msg or "").rstrip()
|
||||
line = html.escape(plain_line)
|
||||
msg = (html_msg or "").rstrip()
|
||||
if msg:
|
||||
return f"{msg}\n{line}"
|
||||
return line
|
||||
|
||||
|
||||
def resolve_product_label(host: Host | None, platform: Platform | None = None) -> tuple[str | None, str | None]:
|
||||
if host is None:
|
||||
return None, None
|
||||
product = (host.product or "").strip()
|
||||
if not product:
|
||||
if platform == "ssh":
|
||||
product = "ssh-monitor"
|
||||
elif platform == "windows":
|
||||
product = "rdp-login-monitor"
|
||||
version = (host.product_version or "").strip() or None
|
||||
return (product or None), version
|
||||
|
||||
|
||||
def host_server_line(host: Host | None) -> str:
|
||||
if host is None:
|
||||
return "unknown"
|
||||
label = (host.display_name or host.hostname or "unknown").strip()
|
||||
ip = (host.ipv4 or "").strip()
|
||||
if ip:
|
||||
return f"{label} ({ip})"
|
||||
return label
|
||||
|
||||
|
||||
def collapse_blank_lines(text: str, *, max_run: int = 1) -> str:
|
||||
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
out: list[str] = []
|
||||
blank_run = 0
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
blank_run += 1
|
||||
if blank_run <= max_run:
|
||||
out.append("")
|
||||
continue
|
||||
blank_run = 0
|
||||
out.append(line.rstrip())
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def is_active_users_empty_line(text: str) -> bool:
|
||||
"""Строка-заглушка вместо списка сессий (агент или SAC)."""
|
||||
s = text.strip()
|
||||
if not s:
|
||||
return True
|
||||
inner = s.lstrip("👤").strip()
|
||||
if _ACTIVE_USERS_EMPTY_LINE_RE.search(inner):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def split_active_user_tokens(entry: str) -> list[str]:
|
||||
"""Разбивает «👤 u1 👤 u2» на отдельные строки (как в отчёте агента)."""
|
||||
entry = entry.strip()
|
||||
if not entry or is_active_users_empty_line(entry):
|
||||
return []
|
||||
if entry.count("👤") <= 1:
|
||||
line = entry if entry.startswith("👤") else f"👤 {entry}"
|
||||
return [f" {line}"]
|
||||
parts = re.split(r"(?=👤)", entry)
|
||||
result: list[str] = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if not part.startswith("👤"):
|
||||
part = f"👤 {part}"
|
||||
result.append(f" {part}")
|
||||
return result
|
||||
|
||||
|
||||
def normalize_active_users_list(users: list[Any] | None) -> list[str]:
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in users or []:
|
||||
for line in split_active_user_tokens(str(raw)):
|
||||
key = line.strip().lower()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def format_agent_version_line(version: str) -> str:
|
||||
v = (version or "").strip()
|
||||
if not v:
|
||||
return ""
|
||||
return f"Agent version {v}"
|
||||
|
||||
|
||||
def ensure_agent_version_line(body: str, version: str | None) -> str:
|
||||
line = format_agent_version_line(version or "")
|
||||
if not line:
|
||||
return body
|
||||
if _AGENT_VERSION_LINE_RE.search(body):
|
||||
return body
|
||||
lines = body.replace("\r\n", "\n").split("\n")
|
||||
for i, ln in enumerate(lines):
|
||||
if _DAILY_REPORT_TITLE_MARKER in ln:
|
||||
lines.insert(i + 1, line)
|
||||
return "\n".join(lines)
|
||||
return body
|
||||
|
||||
|
||||
def _fix_active_users_header_count(header_line: str, user_count: int) -> str:
|
||||
stripped = header_line.rstrip()
|
||||
if _ACTIVE_USERS_HEADER_RE.match(stripped.strip()):
|
||||
return re.sub(
|
||||
r"(\👥\s*АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ\s*\()[^)]+(\))",
|
||||
rf"\g<1>{user_count}\2",
|
||||
stripped,
|
||||
count=1,
|
||||
)
|
||||
return stripped
|
||||
|
||||
|
||||
def ensure_server_line(body: str, host: Host | None) -> str:
|
||||
server = host_server_line(host)
|
||||
if not server or server == "unknown":
|
||||
return body
|
||||
if _SERVER_LINE_RE.search(body):
|
||||
return body
|
||||
lines = body.replace("\r\n", "\n").split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if "ЕЖЕДНЕВНЫЙ ОТЧЕТ" in line:
|
||||
insert_at = i + 1
|
||||
if insert_at < len(lines) and _AGENT_VERSION_LINE_RE.match(lines[insert_at].strip()):
|
||||
insert_at += 1
|
||||
lines.insert(insert_at, f"🖥️ Сервер: {server}")
|
||||
return "\n".join(lines)
|
||||
return f"🖥️ Сервер: {server}\n{body}"
|
||||
|
||||
|
||||
def normalize_active_users_in_body(body: str) -> str:
|
||||
lines = body.replace("\r\n", "\n").split("\n")
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if _ACTIVE_USERS_HEADER_RE.match(line.strip()):
|
||||
out.append(line)
|
||||
i += 1
|
||||
user_lines: list[str] = []
|
||||
while i < len(lines):
|
||||
cur = lines[i]
|
||||
stripped = cur.strip()
|
||||
if not stripped:
|
||||
break
|
||||
if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX):
|
||||
break
|
||||
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
|
||||
break
|
||||
if is_active_users_empty_line(stripped):
|
||||
out[-1] = _fix_active_users_header_count(out[-1], 0)
|
||||
out.append(cur)
|
||||
i += 1
|
||||
break
|
||||
user_lines.extend(split_active_user_tokens(cur))
|
||||
i += 1
|
||||
user_lines = [ln for ln in user_lines if not is_active_users_empty_line(ln.strip())]
|
||||
if user_lines:
|
||||
out[-1] = _fix_active_users_header_count(out[-1], len(user_lines))
|
||||
out.extend(user_lines)
|
||||
else:
|
||||
out[-1] = _fix_active_users_header_count(out[-1], 0)
|
||||
continue
|
||||
out.append(line)
|
||||
i += 1
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def extract_active_users_from_report_body(body: str) -> list[str]:
|
||||
lines = body.replace("\r\n", "\n").split("\n")
|
||||
in_section = False
|
||||
users: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if _ACTIVE_USERS_HEADER_RE.match(stripped):
|
||||
in_section = True
|
||||
continue
|
||||
if not in_section:
|
||||
continue
|
||||
if not stripped:
|
||||
break
|
||||
if is_active_users_empty_line(stripped):
|
||||
break
|
||||
if stripped.startswith("Источник:") or stripped.startswith(NOTIFICATION_SOURCE_PREFIX):
|
||||
break
|
||||
if _SECTION_HEADER_RE.match(stripped) and not stripped.startswith("👤"):
|
||||
break
|
||||
users.extend(split_active_user_tokens(stripped))
|
||||
return normalize_active_users_list(users)
|
||||
|
||||
|
||||
def reconcile_stats_from_report_body(
|
||||
stats: dict[str, Any],
|
||||
body: str,
|
||||
platform: Platform,
|
||||
) -> dict[str, Any]:
|
||||
"""Синхронизирует stats.active_users и счётчики с текстом отчёта после нормализации."""
|
||||
out = dict(stats)
|
||||
users = extract_active_users_from_report_body(body)
|
||||
out["active_users"] = users
|
||||
count = len(users)
|
||||
if platform == "ssh":
|
||||
out["active_sessions"] = count
|
||||
else:
|
||||
out["active_sessions_rdp"] = count
|
||||
names: list[str] = []
|
||||
for raw in users:
|
||||
name = re.sub(r"^👤\s*", "", raw.strip()).strip()
|
||||
if name and not is_active_users_empty_line(name):
|
||||
names.append(name)
|
||||
out["unique_users"] = sorted(set(names))
|
||||
return out
|
||||
|
||||
|
||||
def normalize_report_body(body: str, host: Host | None, platform: Platform) -> str:
|
||||
"""Приводит текст отчёта (агент/SAC) к единому компактному виду."""
|
||||
text = collapse_blank_lines(body.replace("\r\n", "\n"))
|
||||
agent_version = host.product_version if host else None
|
||||
text = ensure_agent_version_line(text, agent_version)
|
||||
text = ensure_server_line(text, host)
|
||||
text = normalize_active_users_in_body(text)
|
||||
return collapse_blank_lines(text)
|
||||
|
||||
|
||||
def body_to_report_html(body: str) -> str:
|
||||
escaped = html.escape(body)
|
||||
return f'<div class="agent-report">{escaped.replace(chr(10), "<br>")}</div>'
|
||||
|
||||
|
||||
def _section_top_ips(top: list[str]) -> list[str]:
|
||||
lines = [" 🧾 ТОП-5 IP ПО НЕУДАЧНЫМ ПОПЫТКАМ:"]
|
||||
if top:
|
||||
lines.extend(f" {line}" if not line.startswith(" ") else line for line in top[:5])
|
||||
else:
|
||||
lines.append(" (нет данных)")
|
||||
return lines
|
||||
|
||||
|
||||
def _section_active_users(users: list[str], *, sac_generated: bool) -> list[str]:
|
||||
count = len(users)
|
||||
lines = [f" 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ ({count}):"]
|
||||
if users:
|
||||
for line in users:
|
||||
lines.append(f" {line}" if not line.startswith(" ") else line)
|
||||
elif sac_generated:
|
||||
lines.append(
|
||||
" (нет данных — полный список сессий только в отчёте агента; "
|
||||
"в SAC — пользователи из событий входа за 24 ч, если были)"
|
||||
)
|
||||
else:
|
||||
lines.append(" (нет данных)")
|
||||
return lines
|
||||
|
||||
|
||||
def build_report_body(
|
||||
platform: Platform,
|
||||
host: Host,
|
||||
stats: dict[str, Any],
|
||||
when_local: datetime,
|
||||
*,
|
||||
sac_generated: bool = False,
|
||||
) -> str:
|
||||
server = host_server_line(host)
|
||||
time_str = when_local.strftime("%d.%m.%Y %H:%M:%S")
|
||||
top = list(stats.get("top_failed_ips") or [])[:5]
|
||||
active_users = normalize_active_users_list(list(stats.get("active_users") or []))
|
||||
agent_version = (
|
||||
str(stats.get("agent_version") or stats.get("product_version") or host.product_version or "")
|
||||
).strip()
|
||||
if sac_generated and not agent_version:
|
||||
agent_version = "sac"
|
||||
|
||||
if platform == "ssh":
|
||||
title = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА"
|
||||
ok = int(stats.get("successful_logins", stats.get("successful_ssh", 0)))
|
||||
fail = int(stats.get("failed_logins", stats.get("failed_ssh", 0)))
|
||||
sudo = int(stats.get("sudo_commands", 0))
|
||||
bans = int(stats.get("active_bans", 0))
|
||||
lines = [title]
|
||||
if agent_version:
|
||||
lines.append(format_agent_version_line(agent_version))
|
||||
lines.extend([
|
||||
f"🖥️ Сервер: {server}",
|
||||
f"🕐 Время отчета: {time_str}",
|
||||
"",
|
||||
" 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:",
|
||||
f" ✅ Успешных SSH подключений: {ok}",
|
||||
f" ❌ Неудачных попыток SSH: {fail}",
|
||||
f" ⚠️ Команд через sudo: {sudo}",
|
||||
f" 🚫 Активных банов (ipset): {bans}",
|
||||
])
|
||||
else:
|
||||
title = "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS"
|
||||
ok = int(stats.get("successful_logins", stats.get("rdp_success", 0)))
|
||||
fail = int(stats.get("failed_logins", stats.get("rdp_failed", 0)))
|
||||
bans = int(stats.get("active_bans", 0))
|
||||
lines = [title]
|
||||
if agent_version:
|
||||
lines.append(format_agent_version_line(agent_version))
|
||||
lines.extend([
|
||||
f"🖥️ Сервер: {server}",
|
||||
f"🕐 Время отчета: {time_str}",
|
||||
"",
|
||||
" 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:",
|
||||
f" ✅ Успешных RDP подключений: {ok}",
|
||||
f" ❌ Неудачных попыток RDP: {fail}",
|
||||
f" 🚫 Активных банов: {bans}",
|
||||
])
|
||||
|
||||
lines.append("")
|
||||
lines.extend(_section_top_ips(top))
|
||||
lines.append("")
|
||||
lines.extend(_section_active_users(active_users, sac_generated=sac_generated))
|
||||
|
||||
generated_by = "sac" if sac_generated else "agent"
|
||||
product, version = resolve_product_label(host, platform)
|
||||
if not sac_generated and agent_version:
|
||||
version = agent_version
|
||||
lines.append("")
|
||||
lines.append(
|
||||
format_notification_source_plain(
|
||||
generated_by=generated_by,
|
||||
product=product,
|
||||
product_version=version,
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def enrich_stats_for_storage(platform: Platform, stats: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Канонические поля + legacy-ключи для UI и старых отчётов."""
|
||||
out = dict(stats)
|
||||
out["platform"] = platform
|
||||
if platform == "ssh":
|
||||
ok = int(out.get("successful_logins", out.get("successful_ssh", 0)))
|
||||
fail = int(out.get("failed_logins", out.get("failed_ssh", 0)))
|
||||
out["successful_logins"] = ok
|
||||
out["failed_logins"] = fail
|
||||
out["successful_ssh"] = ok
|
||||
out["failed_ssh"] = fail
|
||||
else:
|
||||
ok = int(out.get("successful_logins", out.get("rdp_success", 0)))
|
||||
fail = int(out.get("failed_logins", out.get("rdp_failed", 0)))
|
||||
out["successful_logins"] = ok
|
||||
out["failed_logins"] = fail
|
||||
out["rdp_success"] = ok
|
||||
out["rdp_failed"] = fail
|
||||
if "unique_users" in out and "active_users" not in out:
|
||||
users = out.get("unique_users") or []
|
||||
out["active_users"] = [f"👤 {u}" if not str(u).startswith("👤") else str(u) for u in users]
|
||||
if "active_users" in out:
|
||||
out["active_users"] = normalize_active_users_list(list(out.get("active_users") or []))
|
||||
return out
|
||||
|
||||
|
||||
def normalize_daily_report_details(
|
||||
details: dict[str, Any] | None,
|
||||
host: Host | None,
|
||||
report_type: str,
|
||||
) -> dict[str, Any] | None:
|
||||
if not isinstance(details, dict):
|
||||
return details
|
||||
platform: Platform = "windows" if report_type == "report.daily.rdp" else "ssh"
|
||||
body = details.get("report_body")
|
||||
if not isinstance(body, str) or not body.strip():
|
||||
return details
|
||||
|
||||
normalized_body = normalize_report_body(body, host, platform)
|
||||
gb = str(details.get("generated_by") or "agent").strip().lower()
|
||||
if gb not in ("agent", "sac"):
|
||||
gb = "agent"
|
||||
product, version = resolve_product_label(host, platform)
|
||||
stats_raw = details.get("stats")
|
||||
if isinstance(stats_raw, dict) and gb == "agent":
|
||||
av = stats_raw.get("agent_version") or stats_raw.get("product_version")
|
||||
if av:
|
||||
version = str(av).strip()
|
||||
normalized_body = append_notification_source_plain(
|
||||
normalized_body,
|
||||
generated_by=gb,
|
||||
product=product,
|
||||
product_version=version,
|
||||
)
|
||||
out = dict(details)
|
||||
out["report_body"] = normalized_body
|
||||
out["report_html"] = body_to_report_html(normalized_body)
|
||||
|
||||
stats = out.get("stats")
|
||||
if isinstance(stats, dict):
|
||||
stats = enrich_stats_for_storage(platform, dict(stats))
|
||||
stats = reconcile_stats_from_report_body(stats, normalized_body, platform)
|
||||
out["stats"] = stats
|
||||
return out
|
||||
@@ -0,0 +1,131 @@
|
||||
"""SMTP email notifications from SAC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Problem
|
||||
from app.services.notification_severity import severity_meets_minimum
|
||||
from app.services.notification_settings import EmailConfig, get_effective_email_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailNotConfiguredError(Exception):
|
||||
"""Email disabled or missing SMTP settings."""
|
||||
|
||||
|
||||
class EmailSendError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def parse_mail_recipients(mail_to: str) -> list[str]:
|
||||
return [part.strip() for part in re.split(r"[,;]", mail_to) if part.strip()]
|
||||
|
||||
|
||||
def send_email_message(
|
||||
subject: str,
|
||||
body: str,
|
||||
*,
|
||||
config: EmailConfig | None = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
cfg = config or get_effective_email_config()
|
||||
if not force and not cfg.active:
|
||||
return
|
||||
if not cfg.configured:
|
||||
if force:
|
||||
raise EmailNotConfiguredError("SMTP: не задан host, from или to")
|
||||
return
|
||||
|
||||
recipients = parse_mail_recipients(cfg.mail_to)
|
||||
if not recipients:
|
||||
if force:
|
||||
raise EmailNotConfiguredError("SMTP: список получателей пуст")
|
||||
return
|
||||
|
||||
msg = MIMEText(body, "plain", "utf-8")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = cfg.mail_from
|
||||
msg["To"] = ", ".join(recipients)
|
||||
|
||||
try:
|
||||
if cfg.smtp_ssl:
|
||||
server: smtplib.SMTP = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=12)
|
||||
else:
|
||||
server = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=12)
|
||||
with server:
|
||||
server.ehlo()
|
||||
if cfg.smtp_starttls and not cfg.smtp_ssl:
|
||||
server.starttls()
|
||||
server.ehlo()
|
||||
if cfg.smtp_user:
|
||||
server.login(cfg.smtp_user, cfg.smtp_password)
|
||||
server.sendmail(cfg.mail_from, recipients, msg.as_string())
|
||||
except Exception as exc:
|
||||
logger.exception("smtp send failed")
|
||||
raise EmailSendError(str(exc)) from exc
|
||||
|
||||
|
||||
def send_email_test_message(*, config: EmailConfig | None = None) -> None:
|
||||
cfg = config or get_effective_email_config()
|
||||
if not cfg.enabled:
|
||||
raise EmailNotConfiguredError("Email отключён (enabled=false)")
|
||||
if not cfg.configured:
|
||||
raise EmailNotConfiguredError("Не задан SMTP host, from или to")
|
||||
send_email_message(
|
||||
"SAC: тестовое письмо",
|
||||
"Тестовое сообщение Security Alert Center.\nКанал SMTP для оповещений работает.",
|
||||
config=cfg,
|
||||
force=True,
|
||||
)
|
||||
|
||||
|
||||
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
|
||||
cfg = get_effective_email_config(db)
|
||||
if not cfg.active:
|
||||
return
|
||||
if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity):
|
||||
return
|
||||
host = event.host.hostname if event.host else "unknown"
|
||||
body = (
|
||||
f"SAC событие\n"
|
||||
f"Host: {host}\n"
|
||||
f"Severity: {event.severity}\n"
|
||||
f"Type: {event.type}\n"
|
||||
f"Title: {event.title}\n"
|
||||
f"Summary: {event.summary}"
|
||||
)
|
||||
try:
|
||||
send_email_message(f"SAC: {event.type} ({event.severity})", body, config=cfg)
|
||||
except EmailSendError:
|
||||
logger.exception("notify_event email failed event_id=%s", event.event_id)
|
||||
|
||||
|
||||
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
|
||||
cfg = get_effective_email_config(db)
|
||||
if not cfg.active:
|
||||
return
|
||||
if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity):
|
||||
return
|
||||
host = problem.host.hostname if problem.host else "unknown"
|
||||
related = ""
|
||||
if event is not None:
|
||||
related = f"\nEvent: {event.type} ({event.severity})"
|
||||
body = (
|
||||
f"SAC Problem opened\n"
|
||||
f"Host: {host}\n"
|
||||
f"Severity: {problem.severity}\n"
|
||||
f"Rule: {problem.rule_id or '-'}\n"
|
||||
f"Title: {problem.title}\n"
|
||||
f"Summary: {problem.summary}{related}"
|
||||
)
|
||||
try:
|
||||
send_email_message(f"SAC Problem: {problem.title}", body, config=cfg)
|
||||
except EmailSendError:
|
||||
logger.exception("notify_problem email failed problem_id=%s", problem.id)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Имя пользователя для событий подключения/аутентификации (колонка «Пользователь» в UI)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# События, где в details может быть учётная запись (SSH/RDP/sudo/logind и т.д.).
|
||||
ACTOR_USER_EVENT_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"ssh.login.success",
|
||||
"ssh.login.failed",
|
||||
"privilege.sudo.command",
|
||||
"session.logind.new",
|
||||
"session.logind.removed",
|
||||
"session.logind.failed",
|
||||
"rdp.login.success",
|
||||
"rdp.login.failed",
|
||||
"rdp.session.logoff",
|
||||
"rdp.shadow.control.started",
|
||||
"rdp.shadow.control.stopped",
|
||||
"rdp.shadow.control.permission",
|
||||
"winrm.session.started",
|
||||
"smb.admin_share.access",
|
||||
"auth.explicit.credentials",
|
||||
"rdg.connection.success",
|
||||
"rdg.connection.disconnected",
|
||||
"rdg.connection.failed",
|
||||
}
|
||||
)
|
||||
|
||||
_SHADOW_DETAIL_KEYS = ("shadower_user", "user", "username")
|
||||
_DEFAULT_DETAIL_KEYS = ("user", "username")
|
||||
|
||||
|
||||
def _pick_detail_value(details: dict[str, Any], keys: tuple[str, ...]) -> str | None:
|
||||
for key in keys:
|
||||
val = details.get(key)
|
||||
if val is not None and str(val).strip() not in ("", "-"):
|
||||
return str(val).strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_event_actor_user(event_type: str, details: dict[str, Any] | None) -> str | None:
|
||||
"""Вернуть имя пользователя для auth/connect событий или None (UI покажет «—»)."""
|
||||
if event_type not in ACTOR_USER_EVENT_TYPES:
|
||||
return None
|
||||
if not isinstance(details, dict):
|
||||
return None
|
||||
keys = _SHADOW_DETAIL_KEYS if event_type.startswith("rdp.shadow.") else _DEFAULT_DETAIL_KEYS
|
||||
return _pick_detail_value(details, keys)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Per event-type severity overrides (admin Settings UI)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.constants.event_types import DEFAULT_EVENT_SEVERITIES
|
||||
from app.models.event_severity_override import EventSeverityOverride
|
||||
from app.services.event_type_visibility import get_visibility_map
|
||||
from app.services.notification_settings import VALID_SEVERITIES
|
||||
|
||||
SOURCE_DB = "db"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EventSeverityOverrideItem:
|
||||
event_type: str
|
||||
default_severity: str
|
||||
override_severity: str | None
|
||||
show_in_events: bool = True
|
||||
|
||||
|
||||
def get_override_map(db: Session) -> dict[str, str]:
|
||||
rows = db.scalars(select(EventSeverityOverride)).all()
|
||||
return {row.event_type: row.severity for row in rows}
|
||||
|
||||
|
||||
def apply_severity_override(payload: dict, db: Session) -> dict:
|
||||
"""Return payload copy with severity replaced when override exists."""
|
||||
event_type = str(payload.get("type") or "")
|
||||
if not event_type:
|
||||
return payload
|
||||
override_map = get_override_map(db)
|
||||
override = override_map.get(event_type)
|
||||
if not override:
|
||||
return payload
|
||||
agent_severity = str(payload.get("severity") or "info")
|
||||
if agent_severity == override:
|
||||
return payload
|
||||
out = dict(payload)
|
||||
details = dict(out.get("details") or {})
|
||||
details["severity_agent"] = agent_severity
|
||||
out["details"] = details
|
||||
out["severity"] = override
|
||||
return out
|
||||
|
||||
|
||||
def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]:
|
||||
override_map = get_override_map(db)
|
||||
visibility_map = get_visibility_map(db)
|
||||
types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map) | set(visibility_map)
|
||||
items: list[EventSeverityOverrideItem] = []
|
||||
for event_type in sorted(types):
|
||||
default = DEFAULT_EVENT_SEVERITIES.get(event_type, "info")
|
||||
items.append(
|
||||
EventSeverityOverrideItem(
|
||||
event_type=event_type,
|
||||
default_severity=default,
|
||||
override_severity=override_map.get(event_type),
|
||||
show_in_events=visibility_map.get(event_type, True),
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def replace_severity_overrides(db: Session, overrides: dict[str, str | None]) -> list[EventSeverityOverrideItem]:
|
||||
for event_type, severity in overrides.items():
|
||||
normalized_type = event_type.strip()
|
||||
if not normalized_type:
|
||||
raise ValueError("event_type is required")
|
||||
if severity is None or severity == "":
|
||||
db.execute(
|
||||
delete(EventSeverityOverride).where(EventSeverityOverride.event_type == normalized_type)
|
||||
)
|
||||
continue
|
||||
if severity not in VALID_SEVERITIES:
|
||||
raise ValueError(f"severity must be one of: {sorted(VALID_SEVERITIES)}")
|
||||
row = db.get(EventSeverityOverride, normalized_type)
|
||||
if row is None:
|
||||
row = EventSeverityOverride(event_type=normalized_type, severity=severity)
|
||||
db.add(row)
|
||||
else:
|
||||
row.severity = severity
|
||||
db.flush()
|
||||
return list_severity_override_items(db)
|
||||
@@ -0,0 +1,57 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.event import Event
|
||||
from app.schemas.list_models import EventSummary
|
||||
from app.services.event_actor_user import extract_event_actor_user
|
||||
from app.services.host_sessions import event_session_terminated
|
||||
from app.services.rdg_display import build_rdg_display
|
||||
from app.services.rdg_session_flap import resolve_rdg_flap_summary
|
||||
from app.services.session_duration import extract_session_duration_sec
|
||||
|
||||
|
||||
def event_to_summary(event: Event, db: Session | None = None) -> EventSummary:
|
||||
host = event.host
|
||||
rdg_flap = False
|
||||
rdg_flap_pair_event_id: int | None = None
|
||||
rdg_flap_qwinsta_event_id: int | None = None
|
||||
if db is not None:
|
||||
rdg_flap, rdg_flap_pair_event_id, rdg_flap_qwinsta_event_id = resolve_rdg_flap_summary(
|
||||
db, event
|
||||
)
|
||||
|
||||
title = event.title
|
||||
summary = event.summary
|
||||
rdg_access_path: str | None = None
|
||||
rdg_qwinsta_enabled = False
|
||||
rdg_display = build_rdg_display(event, db)
|
||||
if rdg_display is not None:
|
||||
title = rdg_display.title
|
||||
summary = rdg_display.summary
|
||||
rdg_access_path = rdg_display.access_path
|
||||
rdg_qwinsta_enabled = rdg_display.qwinsta_enabled
|
||||
|
||||
return EventSummary(
|
||||
id=event.id,
|
||||
event_id=event.event_id,
|
||||
host_id=event.host_id,
|
||||
hostname=host.hostname,
|
||||
display_name=host.display_name,
|
||||
product_version=host.product_version,
|
||||
occurred_at=event.occurred_at,
|
||||
received_at=event.received_at,
|
||||
category=event.category,
|
||||
type=event.type,
|
||||
severity=event.severity,
|
||||
title=title,
|
||||
summary=summary,
|
||||
actor_user=extract_event_actor_user(event.type, event.details),
|
||||
session_duration_sec=extract_session_duration_sec(
|
||||
event.details if isinstance(event.details, dict) else None
|
||||
),
|
||||
rdg_flap=rdg_flap,
|
||||
rdg_flap_pair_event_id=rdg_flap_pair_event_id,
|
||||
rdg_flap_qwinsta_event_id=rdg_flap_qwinsta_event_id,
|
||||
rdg_access_path=rdg_access_path,
|
||||
rdg_qwinsta_enabled=rdg_qwinsta_enabled,
|
||||
session_terminated=event_session_terminated(event, db=db),
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Hide selected event types from SAC UI/API event lists."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
from app.models.event import Event
|
||||
from app.models.event_type_visibility import EventTypeVisibility
|
||||
|
||||
|
||||
def get_hidden_event_types(db: Session) -> frozenset[str]:
|
||||
rows = db.scalars(
|
||||
select(EventTypeVisibility.event_type).where(EventTypeVisibility.show_in_events.is_(False))
|
||||
).all()
|
||||
return frozenset(rows)
|
||||
|
||||
|
||||
def get_visibility_map(db: Session) -> dict[str, bool]:
|
||||
rows = db.scalars(select(EventTypeVisibility)).all()
|
||||
return {row.event_type: row.show_in_events for row in rows}
|
||||
|
||||
|
||||
def show_in_events_for(event_type: str, *, hidden: frozenset[str]) -> bool:
|
||||
return event_type not in hidden
|
||||
|
||||
|
||||
def event_type_notifications_enabled(event_type: str, db: Session | None) -> bool:
|
||||
"""Outbound alerts (Telegram/push/email/webhook) only for types visible in events UI."""
|
||||
if db is None:
|
||||
return True
|
||||
hidden = get_hidden_event_types(db)
|
||||
return show_in_events_for(event_type, hidden=hidden)
|
||||
|
||||
|
||||
def visibility_type_filter(hidden: frozenset[str]) -> ColumnElement[bool] | None:
|
||||
if not hidden:
|
||||
return None
|
||||
return Event.type.not_in(tuple(sorted(hidden)))
|
||||
|
||||
|
||||
def replace_event_visibility(db: Session, visibility: dict[str, bool | None]) -> dict[str, bool]:
|
||||
stored = get_visibility_map(db)
|
||||
for event_type, show in visibility.items():
|
||||
normalized_type = event_type.strip()
|
||||
if not normalized_type:
|
||||
raise ValueError("event_type is required")
|
||||
if show is None or show is True:
|
||||
db.execute(
|
||||
delete(EventTypeVisibility).where(EventTypeVisibility.event_type == normalized_type)
|
||||
)
|
||||
stored.pop(normalized_type, None)
|
||||
continue
|
||||
if show is False:
|
||||
row = db.get(EventTypeVisibility, normalized_type)
|
||||
if row is None:
|
||||
row = EventTypeVisibility(event_type=normalized_type, show_in_events=False)
|
||||
db.add(row)
|
||||
else:
|
||||
row.show_in_events = False
|
||||
stored[normalized_type] = False
|
||||
else:
|
||||
raise ValueError("show_in_events must be true, false, or null")
|
||||
db.flush()
|
||||
return stored
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Best-effort integration with fail2ban for SSH login bans (OS level, not SAC DB)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BANNED_IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fail2banActionResult:
|
||||
ok: bool
|
||||
message: str
|
||||
|
||||
|
||||
def _ssh_jail() -> str:
|
||||
settings = get_settings()
|
||||
jail = (getattr(settings, "sac_fail2ban_ssh_jail", None) or "sshd").strip()
|
||||
return jail or "sshd"
|
||||
|
||||
|
||||
def _run_fail2ban(args: list[str], *, timeout: int = 15) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["fail2ban-client", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def fail2ban_available() -> bool:
|
||||
try:
|
||||
proc = _run_fail2ban(["ping"], timeout=5)
|
||||
return proc.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
def list_ssh_banned_ips() -> list[str]:
|
||||
if not fail2ban_available():
|
||||
return []
|
||||
jail = _ssh_jail()
|
||||
try:
|
||||
proc = _run_fail2ban(["status", jail])
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
logger.warning("fail2ban status failed: %s", exc)
|
||||
return []
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
text = (proc.stdout or "") + "\n" + (proc.stderr or "")
|
||||
ips: list[str] = []
|
||||
capture = False
|
||||
for line in text.splitlines():
|
||||
lower = line.strip().lower()
|
||||
if "banned ip list" in lower:
|
||||
capture = True
|
||||
tail = line.split(":", 1)[-1]
|
||||
ips.extend(_BANNED_IP_RE.findall(tail))
|
||||
continue
|
||||
if capture:
|
||||
if not line.strip():
|
||||
break
|
||||
ips.extend(_BANNED_IP_RE.findall(line))
|
||||
# preserve order, unique
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for ip in ips:
|
||||
if ip not in seen:
|
||||
seen.add(ip)
|
||||
out.append(ip)
|
||||
return out
|
||||
|
||||
|
||||
def unban_ssh_ip(ip_address: str) -> Fail2banActionResult:
|
||||
ip = (ip_address or "").strip()
|
||||
if not ip:
|
||||
return Fail2banActionResult(ok=False, message="empty ip")
|
||||
if not fail2ban_available():
|
||||
return Fail2banActionResult(ok=False, message="fail2ban-client недоступен на сервере SAC")
|
||||
jail = _ssh_jail()
|
||||
try:
|
||||
proc = _run_fail2ban(["set", jail, "unbanip", ip])
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return Fail2banActionResult(ok=False, message=str(exc))
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "unban failed").strip()
|
||||
return Fail2banActionResult(ok=False, message=err[:500])
|
||||
return Fail2banActionResult(ok=True, message=f"SSH: IP {ip} разблокирован (jail {jail})")
|
||||
|
||||
|
||||
def sync_fail2ban_ignore_ips(ip_addresses: list[str]) -> Fail2banActionResult:
|
||||
settings = get_settings()
|
||||
if not getattr(settings, "sac_fail2ban_sync_enabled", False):
|
||||
return Fail2banActionResult(ok=True, message="sync отключён (SAC_FAIL2BAN_SYNC_ENABLED=false)")
|
||||
path = (getattr(settings, "sac_fail2ban_ignoreip_file", None) or "").strip()
|
||||
if not path:
|
||||
return Fail2banActionResult(ok=True, message="файл ignoreip не задан (SAC_FAIL2BAN_IGNOREIP_FILE)")
|
||||
|
||||
jail = _ssh_jail()
|
||||
base_ignore = "127.0.0.1/8 ::1"
|
||||
extra = " ".join(ip for ip in ip_addresses if ip)
|
||||
ignore_line = f"{base_ignore} {extra}".strip()
|
||||
content = (
|
||||
f"# Managed by SAC — login IP whitelist for fail2ban\n"
|
||||
f"[{jail}]\n"
|
||||
f"ignoreip = {ignore_line}\n"
|
||||
)
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
except OSError as exc:
|
||||
return Fail2banActionResult(ok=False, message=f"не удалось записать {path}: {exc}")
|
||||
|
||||
if not fail2ban_available():
|
||||
return Fail2banActionResult(ok=True, message=f"файл записан ({path}); fail2ban reload пропущен")
|
||||
|
||||
try:
|
||||
proc = _run_fail2ban(["reload"], timeout=30)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return Fail2banActionResult(ok=False, message=f"файл записан, reload failed: {exc}")
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "reload failed").strip()
|
||||
return Fail2banActionResult(ok=False, message=f"файл записан, reload: {err[:300]}")
|
||||
return Fail2banActionResult(ok=True, message=f"fail2ban ignoreip обновлён ({path})")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Удаление хоста и связанных events/problems (ручная очистка UI)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import delete, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host, Problem, ProblemEvent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostDeleteResult:
|
||||
host_id: int
|
||||
hostname: str
|
||||
deleted_events: int
|
||||
deleted_problems: int
|
||||
|
||||
|
||||
def delete_host_and_related(db: Session, host_id: int) -> HostDeleteResult | None:
|
||||
host = db.get(Host, host_id)
|
||||
if host is None:
|
||||
return None
|
||||
|
||||
hostname = host.hostname
|
||||
event_ids = list(db.scalars(select(Event.id).where(Event.host_id == host_id)).all())
|
||||
problem_ids = list(db.scalars(select(Problem.id).where(Problem.host_id == host_id)).all())
|
||||
|
||||
if event_ids or problem_ids:
|
||||
link_filter = []
|
||||
if event_ids:
|
||||
link_filter.append(ProblemEvent.event_id.in_(event_ids))
|
||||
if problem_ids:
|
||||
link_filter.append(ProblemEvent.problem_id.in_(problem_ids))
|
||||
db.execute(delete(ProblemEvent).where(or_(*link_filter)))
|
||||
|
||||
if problem_ids:
|
||||
db.execute(delete(Problem).where(Problem.id.in_(problem_ids)))
|
||||
if event_ids:
|
||||
db.execute(delete(Event).where(Event.id.in_(event_ids)))
|
||||
|
||||
db.delete(host)
|
||||
db.flush()
|
||||
|
||||
return HostDeleteResult(
|
||||
host_id=host_id,
|
||||
hostname=hostname,
|
||||
deleted_events=len(event_ids),
|
||||
deleted_problems=len(problem_ids),
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Статус агентов по событиям agent.heartbeat / report.daily.* в БД."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host
|
||||
|
||||
HEARTBEAT_TYPE = "agent.heartbeat"
|
||||
DAILY_REPORT_SSH = "report.daily.ssh"
|
||||
DAILY_REPORT_RDP = "report.daily.rdp"
|
||||
DAILY_REPORT_TYPES = (DAILY_REPORT_SSH, DAILY_REPORT_RDP)
|
||||
|
||||
|
||||
def max_event_time_by_host(db: Session, event_type: str) -> dict[int, datetime]:
|
||||
rows = db.execute(
|
||||
select(Event.host_id, func.max(Event.received_at))
|
||||
.where(Event.type == event_type)
|
||||
.group_by(Event.host_id)
|
||||
).all()
|
||||
return {int(host_id): ts for host_id, ts in rows if ts is not None}
|
||||
|
||||
|
||||
def max_daily_report_time_by_host(db: Session) -> dict[int, datetime]:
|
||||
rows = db.execute(
|
||||
select(Event.host_id, func.max(Event.received_at))
|
||||
.where(Event.type.in_(DAILY_REPORT_TYPES))
|
||||
.group_by(Event.host_id)
|
||||
).all()
|
||||
return {int(host_id): ts for host_id, ts in rows if ts is not None}
|
||||
|
||||
|
||||
def agent_status(
|
||||
last_heartbeat_at: datetime | None,
|
||||
*,
|
||||
stale_minutes: int,
|
||||
now: datetime | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
online — heartbeat не старше порога;
|
||||
stale — был хост, но heartbeat устарел или отсутствует;
|
||||
unknown — heartbeat ещё не приходил.
|
||||
"""
|
||||
if last_heartbeat_at is None:
|
||||
return "unknown"
|
||||
ref = now or datetime.now(timezone.utc)
|
||||
hb = last_heartbeat_at
|
||||
if hb.tzinfo is None:
|
||||
hb = hb.replace(tzinfo=timezone.utc)
|
||||
age_sec = (ref - hb).total_seconds()
|
||||
if age_sec > stale_minutes * 60:
|
||||
return "stale"
|
||||
return "online"
|
||||
|
||||
|
||||
def count_stale_hosts(db: Session, stale_minutes: int) -> int:
|
||||
hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE)
|
||||
host_ids = db.scalars(select(Host.id)).all()
|
||||
return sum(
|
||||
1
|
||||
for host_id in host_ids
|
||||
if agent_status(hb_map.get(host_id), stale_minutes=stale_minutes) == "stale"
|
||||
)
|
||||
|
||||
|
||||
def apply_agent_status_host_filter(
|
||||
stmt,
|
||||
count_stmt,
|
||||
agent_status_filter: str,
|
||||
*,
|
||||
stale_minutes: int,
|
||||
):
|
||||
"""Ограничить выборку Host по online / stale / unknown (как в agent_status)."""
|
||||
from datetime import timedelta
|
||||
|
||||
hb_subq = (
|
||||
select(Event.host_id, func.max(Event.received_at).label("last_hb"))
|
||||
.where(Event.type == HEARTBEAT_TYPE)
|
||||
.group_by(Event.host_id)
|
||||
.subquery()
|
||||
)
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=stale_minutes)
|
||||
|
||||
if agent_status_filter == "online":
|
||||
join = stmt.join(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
count_join = count_stmt.join(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
cond = hb_subq.c.last_hb >= cutoff
|
||||
return join.where(cond), count_join.where(cond)
|
||||
if agent_status_filter == "stale":
|
||||
join = stmt.join(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
count_join = count_stmt.join(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
cond = hb_subq.c.last_hb < cutoff
|
||||
return join.where(cond), count_join.where(cond)
|
||||
if agent_status_filter == "unknown":
|
||||
join = stmt.outerjoin(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
count_join = count_stmt.outerjoin(hb_subq, Host.id == hb_subq.c.host_id)
|
||||
cond = hb_subq.c.last_hb.is_(None)
|
||||
return join.where(cond), count_join.where(cond)
|
||||
return stmt, count_stmt
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Host hardware/software inventory from agent.inventory ingest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.models import Host
|
||||
|
||||
INVENTORY_EVENT_TYPE = "agent.inventory"
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[Any]:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _round_gb(value: Any) -> float | None:
|
||||
try:
|
||||
return round(float(value), 2)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_inventory_payload(details: dict | None) -> dict[str, Any] | None:
|
||||
if not isinstance(details, dict):
|
||||
return None
|
||||
inv = details.get("inventory")
|
||||
return inv if isinstance(inv, dict) else None
|
||||
|
||||
|
||||
def hardware_snapshot(inventory: dict[str, Any]) -> dict[str, Any]:
|
||||
processors: list[tuple[str, int | None, int | None]] = []
|
||||
for item in _as_list(inventory.get("processor")):
|
||||
row = _as_dict(item)
|
||||
name = str(row.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
cores = row.get("cores")
|
||||
logical = row.get("logical_processors")
|
||||
try:
|
||||
cores_i = int(cores) if cores is not None else None
|
||||
except (TypeError, ValueError):
|
||||
cores_i = None
|
||||
try:
|
||||
logical_i = int(logical) if logical is not None else None
|
||||
except (TypeError, ValueError):
|
||||
logical_i = None
|
||||
processors.append((name, cores_i, logical_i))
|
||||
processors.sort()
|
||||
|
||||
motherboard = _as_dict(inventory.get("motherboard"))
|
||||
mobo_product = str(motherboard.get("product") or "").strip()
|
||||
|
||||
memory_gb = _round_gb(inventory.get("memory_gb"))
|
||||
|
||||
disks: list[tuple[str, float | None, str]] = []
|
||||
for item in _as_list(inventory.get("disks")):
|
||||
row = _as_dict(item)
|
||||
friendly = str(row.get("friendly_name") or "").strip()
|
||||
media = str(row.get("media_type") or "").strip()
|
||||
size_gb = _round_gb(row.get("size_gb"))
|
||||
if friendly:
|
||||
disks.append((friendly, size_gb, media))
|
||||
disks.sort()
|
||||
|
||||
video: list[str] = sorted(
|
||||
{
|
||||
str(_as_dict(item).get("name") or "").strip()
|
||||
for item in _as_list(inventory.get("video"))
|
||||
if str(_as_dict(item).get("name") or "").strip()
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"processor": processors,
|
||||
"motherboard_product": mobo_product,
|
||||
"memory_gb": memory_gb,
|
||||
"disks": disks,
|
||||
"video": video,
|
||||
}
|
||||
|
||||
|
||||
def _format_change(field: str, old: Any, new: Any) -> dict[str, Any]:
|
||||
return {"field": field, "old": old, "new": new}
|
||||
|
||||
|
||||
def diff_hardware(old: dict[str, Any] | None, new: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if not old:
|
||||
return []
|
||||
prev = hardware_snapshot(old)
|
||||
curr = hardware_snapshot(new)
|
||||
changes: list[dict[str, Any]] = []
|
||||
|
||||
if prev["processor"] != curr["processor"]:
|
||||
changes.append(_format_change("processor", prev["processor"], curr["processor"]))
|
||||
if prev["motherboard_product"] != curr["motherboard_product"]:
|
||||
changes.append(
|
||||
_format_change("motherboard", prev["motherboard_product"], curr["motherboard_product"])
|
||||
)
|
||||
if prev["memory_gb"] != curr["memory_gb"]:
|
||||
changes.append(_format_change("memory_gb", prev["memory_gb"], curr["memory_gb"]))
|
||||
if prev["disks"] != curr["disks"]:
|
||||
changes.append(_format_change("disks", prev["disks"], curr["disks"]))
|
||||
if prev["video"] != curr["video"]:
|
||||
changes.append(_format_change("video", prev["video"], curr["video"]))
|
||||
return changes
|
||||
|
||||
|
||||
def inventory_fingerprint(inventory: dict[str, Any]) -> str:
|
||||
snap = hardware_snapshot(inventory)
|
||||
raw = json.dumps(snap, sort_keys=True, ensure_ascii=False, default=str)
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def apply_os_version_from_inventory(host: Host, inventory: dict[str, Any]) -> None:
|
||||
windows = _as_dict(inventory.get("windows"))
|
||||
product = str(windows.get("product_name") or "").strip()
|
||||
version = str(windows.get("version") or "").strip()
|
||||
if product and version:
|
||||
host.os_version = f"{product} ({version})"
|
||||
elif product:
|
||||
host.os_version = product
|
||||
elif version:
|
||||
host.os_version = version
|
||||
|
||||
|
||||
def process_inventory_ingest(
|
||||
host: Host,
|
||||
details: dict | None,
|
||||
) -> tuple[str, list[dict[str, Any]], dict[str, Any] | None]:
|
||||
"""
|
||||
Update host.inventory from agent.inventory details.
|
||||
|
||||
Returns (severity, hardware_changes, merged_details_patch).
|
||||
severity is warning when hardware changed, else info.
|
||||
"""
|
||||
inventory = extract_inventory_payload(details)
|
||||
if inventory is None:
|
||||
return "info", [], None
|
||||
|
||||
previous = host.inventory if isinstance(host.inventory, dict) else None
|
||||
changes = diff_hardware(previous, inventory)
|
||||
now = datetime.now(timezone.utc)
|
||||
host.inventory = inventory
|
||||
host.inventory_updated_at = now
|
||||
apply_os_version_from_inventory(host, inventory)
|
||||
|
||||
patch: dict[str, Any] = {
|
||||
"inventory_fingerprint": inventory_fingerprint(inventory),
|
||||
"first_inventory": previous is None,
|
||||
}
|
||||
if changes:
|
||||
patch["hardware_changes"] = changes
|
||||
|
||||
severity = "warning" if changes else "info"
|
||||
return severity, changes, patch
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Probe remote host and register in SAC before agent deploy (WinRM / SSH)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Host
|
||||
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH
|
||||
from app.services.linux_admin_settings import get_effective_linux_admin_config
|
||||
from app.services.ssh_connect import _is_ipv4, _ssh_output_hostname, test_ssh_connection
|
||||
from app.services.win_admin_settings import get_effective_win_admin_config
|
||||
from app.services.winrm_connect import test_winrm_connection
|
||||
|
||||
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||||
|
||||
|
||||
class ManualHostAddError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def validate_windows_target(target: str) -> str:
|
||||
text = (target or "").strip()
|
||||
if not text:
|
||||
raise ManualHostAddError("Укажите имя Windows-ПК")
|
||||
if _IPV4_RE.match(text):
|
||||
raise ManualHostAddError(
|
||||
"Для Windows укажите имя ПК (NetBIOS/DNS), не IP — WinRM с Kerberos по IP ненадёжен"
|
||||
)
|
||||
short = text.split(".")[0].split("\\")[-1].strip()
|
||||
if not short or " " in short:
|
||||
raise ManualHostAddError("Некорректное имя хоста")
|
||||
return short
|
||||
|
||||
|
||||
def validate_linux_target(target: str) -> str:
|
||||
text = (target or "").strip()
|
||||
if not text:
|
||||
raise ManualHostAddError("Укажите IP или hostname Linux-хоста")
|
||||
return text
|
||||
|
||||
|
||||
def probe_windows_host(db: Session, hostname: str) -> str:
|
||||
cfg = get_effective_win_admin_config(db)
|
||||
if not cfg.configured:
|
||||
raise ManualHostAddError("Windows domain admin не настроен в SAC")
|
||||
probe = test_winrm_connection(
|
||||
target=hostname,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
)
|
||||
if not probe.ok:
|
||||
raise ManualHostAddError(probe.message)
|
||||
return (probe.hostname or hostname).strip()
|
||||
|
||||
|
||||
def probe_linux_host(db: Session, target: str) -> tuple[str, str | None]:
|
||||
cfg = get_effective_linux_admin_config(db)
|
||||
if not cfg.configured:
|
||||
raise ManualHostAddError("Linux SSH admin не настроен в SAC")
|
||||
probe = test_ssh_connection(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
)
|
||||
if not probe.ok:
|
||||
raise ManualHostAddError(probe.message)
|
||||
hostname = _ssh_output_hostname(probe.stdout) or target
|
||||
ipv4 = target if _is_ipv4(target) else None
|
||||
return hostname, ipv4
|
||||
|
||||
|
||||
def get_or_create_manual_host(
|
||||
db: Session,
|
||||
*,
|
||||
hostname: str,
|
||||
product: str,
|
||||
os_family: str,
|
||||
ipv4: str | None = None,
|
||||
) -> Host:
|
||||
host = db.scalar(
|
||||
select(Host).where(Host.hostname == hostname, Host.product == product).limit(1)
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
if host is None:
|
||||
host = Host(
|
||||
hostname=hostname,
|
||||
os_family=os_family,
|
||||
product=product,
|
||||
ipv4=ipv4,
|
||||
last_seen_at=now,
|
||||
)
|
||||
db.add(host)
|
||||
else:
|
||||
host.os_family = os_family
|
||||
if ipv4:
|
||||
host.ipv4 = ipv4
|
||||
host.last_seen_at = now
|
||||
db.flush()
|
||||
return host
|
||||
|
||||
|
||||
def prepare_manual_host_add(db: Session, *, platform: str, target: str) -> Host:
|
||||
platform_norm = (platform or "").strip().lower()
|
||||
if platform_norm == "windows":
|
||||
win_target = validate_windows_target(target)
|
||||
hostname = probe_windows_host(db, win_target)
|
||||
return get_or_create_manual_host(
|
||||
db,
|
||||
hostname=hostname,
|
||||
product=PRODUCT_RDP,
|
||||
os_family="windows",
|
||||
)
|
||||
if platform_norm == "linux":
|
||||
linux_target = validate_linux_target(target)
|
||||
hostname, ipv4 = probe_linux_host(db, linux_target)
|
||||
return get_or_create_manual_host(
|
||||
db,
|
||||
hostname=hostname,
|
||||
product=PRODUCT_SSH,
|
||||
os_family="linux",
|
||||
ipv4=ipv4,
|
||||
)
|
||||
raise ManualHostAddError("platform должен быть windows или linux")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,449 @@
|
||||
"""Background SSH/WinRM host actions (survives UI navigation and multi-worker API)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import Host
|
||||
from app.services.agent_update import execute_agent_update_fallback
|
||||
from app.services.agent_update_types import AgentUpdateFallbackResult
|
||||
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.ssh_connect import (
|
||||
iter_ssh_targets,
|
||||
run_ssh_monitor_update,
|
||||
SshCommandResult,
|
||||
tail_ssh_monitor_update_log,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ActionRunner = Callable[[Session, Host], AgentUpdateFallbackResult | SshCommandResult]
|
||||
|
||||
_lock = threading.Lock()
|
||||
_running: set[int] = set()
|
||||
|
||||
|
||||
class RemoteActionAlreadyRunningError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _run_inline() -> bool:
|
||||
return os.environ.get("SAC_REMOTE_ACTION_INLINE", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _format_output(result: AgentUpdateFallbackResult | SshCommandResult) -> str:
|
||||
parts: list[str] = []
|
||||
stdout = (getattr(result, "stdout", None) or "").strip()
|
||||
stderr = (getattr(result, "stderr", None) or "").strip()
|
||||
if stdout:
|
||||
parts.append(stdout)
|
||||
if stderr:
|
||||
parts.append(stderr)
|
||||
exit_code = getattr(result, "exit_code", None)
|
||||
if exit_code is not None:
|
||||
parts.append(f"exit code: {exit_code}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _result_payload(
|
||||
result: AgentUpdateFallbackResult | SshCommandResult,
|
||||
*,
|
||||
title: str,
|
||||
started_at: str,
|
||||
) -> dict[str, Any]:
|
||||
finished = _utcnow().isoformat()
|
||||
product_version = getattr(result, "product_version", None) or getattr(result, "agent_version", None)
|
||||
return {
|
||||
"title": title,
|
||||
"status": "success" if result.ok else "failed",
|
||||
"message": result.message,
|
||||
"stdout": getattr(result, "stdout", None) or "",
|
||||
"stderr": getattr(result, "stderr", None) or "",
|
||||
"output": _format_output(result),
|
||||
"ok": result.ok,
|
||||
"exit_code": getattr(result, "exit_code", None),
|
||||
"product_version": product_version,
|
||||
"target": getattr(result, "target", None) or "",
|
||||
"started_at": started_at,
|
||||
"finished_at": finished,
|
||||
}
|
||||
|
||||
|
||||
def _completion_title(base_title: str, result: SshCommandResult) -> str:
|
||||
if result.ok:
|
||||
return f"{base_title} — готово"
|
||||
return f"{base_title} — ошибка"
|
||||
|
||||
|
||||
def _completion_message(result: SshCommandResult) -> str:
|
||||
if result.ok:
|
||||
version = (result.agent_version or "").strip()
|
||||
if version:
|
||||
return f"Обновление завершено успешно. Версия агента: {version}"
|
||||
return "Обновление завершено успешно"
|
||||
return (result.message or "Обновление не удалось").strip()
|
||||
|
||||
|
||||
def _fetch_ssh_update_log_tail(db: Session, host: Host, *, lines: int = 200) -> str:
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
return ""
|
||||
try:
|
||||
targets = iter_ssh_targets(host)
|
||||
except Exception:
|
||||
return ""
|
||||
for target in targets:
|
||||
try:
|
||||
tail = tail_ssh_monitor_update_log(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
lines=lines,
|
||||
)
|
||||
if tail:
|
||||
return tail
|
||||
except Exception:
|
||||
logger.debug("final update log tail failed host_id=%s target=%s", host.id, target, exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
def _enrich_ssh_update_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
base_title: str,
|
||||
result: SshCommandResult,
|
||||
previous_output: str,
|
||||
log_tail: str,
|
||||
) -> dict[str, Any]:
|
||||
payload["title"] = _completion_title(base_title, result)
|
||||
payload["message"] = _completion_message(result)
|
||||
if log_tail:
|
||||
payload["output"] = log_tail
|
||||
elif previous_output.strip():
|
||||
payload["output"] = previous_output
|
||||
return payload
|
||||
|
||||
|
||||
def _mark_running(db: Session, host: Host, *, title: str) -> str:
|
||||
started_at = _utcnow().isoformat()
|
||||
host.agent_update_state = "running"
|
||||
host.agent_update_last_error = None
|
||||
host.remote_action = {
|
||||
"title": title,
|
||||
"status": "running",
|
||||
"message": "Подключение к хосту и выполнение команды…",
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"output": "",
|
||||
"ok": None,
|
||||
"exit_code": None,
|
||||
"product_version": None,
|
||||
"target": host.hostname,
|
||||
"started_at": started_at,
|
||||
"finished_at": None,
|
||||
}
|
||||
db.flush()
|
||||
return started_at
|
||||
|
||||
|
||||
def _apply_ssh_update_result(db: Session, host: Host, result: SshCommandResult) -> None:
|
||||
now = _utcnow()
|
||||
host.agent_update_last_at = now
|
||||
if result.ok:
|
||||
host.agent_update_state = "success"
|
||||
host.agent_update_last_error = None
|
||||
if result.agent_version:
|
||||
host.product_version = result.agent_version
|
||||
host.ssh_admin_ok = True
|
||||
host.ssh_admin_checked_at = now
|
||||
else:
|
||||
host.agent_update_state = "failed"
|
||||
host.agent_update_last_error = (result.message or "SSH update failed")[:2000]
|
||||
|
||||
|
||||
_REMOTE_LOG_POLL_SEC = 2.0
|
||||
|
||||
|
||||
def _poll_ssh_update_log_loop(
|
||||
host_id: int,
|
||||
*,
|
||||
stop: threading.Event,
|
||||
) -> None:
|
||||
"""Периодически подтягивает tail update_script.log в hosts.remote_action для UI."""
|
||||
while not stop.wait(_REMOTE_LOG_POLL_SEC):
|
||||
session = SessionLocal()
|
||||
try:
|
||||
row = session.get(Host, host_id)
|
||||
if row is None or (row.agent_update_state or "").strip().lower() != "running":
|
||||
continue
|
||||
payload = dict(row.remote_action or {})
|
||||
if (payload.get("status") or "").strip().lower() != "running":
|
||||
continue
|
||||
cfg = get_effective_linux_admin_for_host(session, row)
|
||||
if not cfg.configured:
|
||||
continue
|
||||
try:
|
||||
targets = iter_ssh_targets(row)
|
||||
except Exception:
|
||||
continue
|
||||
tail = ""
|
||||
for target in targets:
|
||||
try:
|
||||
tail = tail_ssh_monitor_update_log(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
lines=200,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("update log tail failed host_id=%s target=%s", host_id, target, exc_info=True)
|
||||
continue
|
||||
if tail:
|
||||
break
|
||||
session.refresh(row)
|
||||
if (row.agent_update_state or "").strip().lower() != "running":
|
||||
continue
|
||||
payload = dict(row.remote_action or {})
|
||||
if (payload.get("status") or "").strip().lower() != "running":
|
||||
continue
|
||||
if tail:
|
||||
payload["output"] = tail
|
||||
payload["message"] = "Выполняется обновление… (лог с хоста)"
|
||||
row.remote_action = payload
|
||||
session.commit()
|
||||
except Exception:
|
||||
logger.debug("remote log poll failed host_id=%s", host_id, exc_info=True)
|
||||
session.rollback()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def start_host_remote_action(
|
||||
db: Session,
|
||||
host: Host,
|
||||
*,
|
||||
title: str,
|
||||
runner: ActionRunner,
|
||||
poll_remote_log: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
host_id = int(host.id)
|
||||
with _lock:
|
||||
if host_id in _running or host.agent_update_state == "running":
|
||||
raise RemoteActionAlreadyRunningError(
|
||||
f"Remote action already running for host {host.hostname}"
|
||||
)
|
||||
_running.add(host_id)
|
||||
|
||||
started_at = _mark_running(db, host, title=title)
|
||||
db.commit()
|
||||
|
||||
def _worker() -> None:
|
||||
session = SessionLocal()
|
||||
stop_poll = threading.Event()
|
||||
poll_thread: threading.Thread | None = None
|
||||
if poll_remote_log and runner is run_ssh_monitor_update_action:
|
||||
poll_thread = threading.Thread(
|
||||
target=_poll_ssh_update_log_loop,
|
||||
args=(host_id,),
|
||||
kwargs={"stop": stop_poll},
|
||||
name=f"sac-remote-log-{host_id}",
|
||||
daemon=True,
|
||||
)
|
||||
poll_thread.start()
|
||||
try:
|
||||
row = session.get(Host, host_id)
|
||||
if row is None:
|
||||
return
|
||||
previous_output = (row.remote_action or {}).get("output") or ""
|
||||
result = runner(session, row)
|
||||
started = (row.remote_action or {}).get("started_at") or started_at
|
||||
payload = _result_payload(result, title=title, started_at=started)
|
||||
if poll_remote_log and isinstance(result, SshCommandResult):
|
||||
log_tail = _fetch_ssh_update_log_tail(session, row)
|
||||
payload = _enrich_ssh_update_payload(
|
||||
payload,
|
||||
base_title=title,
|
||||
result=result,
|
||||
previous_output=previous_output,
|
||||
log_tail=log_tail,
|
||||
)
|
||||
row.remote_action = payload
|
||||
if isinstance(result, SshCommandResult):
|
||||
_apply_ssh_update_result(session, row, result)
|
||||
session.commit()
|
||||
except Exception:
|
||||
logger.exception("Background remote action failed for host_id=%s", host_id)
|
||||
session.rollback()
|
||||
row = session.get(Host, host_id)
|
||||
if row is not None:
|
||||
started = (row.remote_action or {}).get("started_at") or started_at
|
||||
row.agent_update_state = "failed"
|
||||
row.agent_update_last_error = "Internal error during remote action"
|
||||
row.remote_action = {
|
||||
"title": title,
|
||||
"status": "failed",
|
||||
"message": "Внутренняя ошибка SAC при выполнении действия",
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"output": "",
|
||||
"ok": False,
|
||||
"exit_code": None,
|
||||
"product_version": None,
|
||||
"target": row.hostname,
|
||||
"started_at": started,
|
||||
"finished_at": _utcnow().isoformat(),
|
||||
}
|
||||
session.commit()
|
||||
finally:
|
||||
stop_poll.set()
|
||||
if poll_thread is not None:
|
||||
poll_thread.join(timeout=5.0)
|
||||
session.close()
|
||||
with _lock:
|
||||
_running.discard(host_id)
|
||||
|
||||
if _run_inline():
|
||||
_worker()
|
||||
else:
|
||||
threading.Thread(target=_worker, name=f"sac-remote-action-{host_id}", daemon=True).start()
|
||||
return dict(host.remote_action or {})
|
||||
|
||||
|
||||
def run_ssh_monitor_update_action(db: Session, host: Host) -> SshCommandResult:
|
||||
cfg = get_effective_linux_admin_for_host(db, host)
|
||||
if not cfg.configured:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message="Linux SSH admin is not configured",
|
||||
target=host.hostname or "",
|
||||
)
|
||||
agent_cfg = get_effective_agent_update_config(db)
|
||||
repo_url = (agent_cfg.ssh_git_repo_url or "").strip() or None
|
||||
branch = (agent_cfg.git_branch or "main").strip() or "main"
|
||||
try:
|
||||
targets = iter_ssh_targets(host)
|
||||
except Exception as exc:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=str(exc),
|
||||
target=host.hostname or "",
|
||||
)
|
||||
last: SshCommandResult | None = None
|
||||
for target in targets:
|
||||
last = run_ssh_monitor_update(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
repo_url=repo_url,
|
||||
git_branch=branch,
|
||||
)
|
||||
if last.ok:
|
||||
return last
|
||||
return last or SshCommandResult(
|
||||
ok=False,
|
||||
message="SSH update failed",
|
||||
target=host.hostname or "",
|
||||
)
|
||||
|
||||
|
||||
def run_agent_fallback_action(db: Session, host: Host) -> AgentUpdateFallbackResult:
|
||||
return execute_agent_update_fallback(db, host)
|
||||
|
||||
|
||||
def clear_stale_running_remote_actions(
|
||||
db: Session,
|
||||
*,
|
||||
reason: str = "Прервано перезапуском SAC (фоновый worker не завершил job)",
|
||||
host_ids: list[int] | None = None,
|
||||
) -> list[str]:
|
||||
"""Сбросить зависшие running после restart sac-api (daemon thread умер, запись в БД осталась)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(Host).where(Host.agent_update_state == "running")
|
||||
if host_ids is not None:
|
||||
stmt = stmt.where(Host.id.in_(host_ids))
|
||||
rows = list(db.scalars(stmt).all())
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
finished = _utcnow().isoformat()
|
||||
hostnames: list[str] = []
|
||||
for host in rows:
|
||||
started = (host.remote_action or {}).get("started_at")
|
||||
payload = dict(host.remote_action or {})
|
||||
payload.update(
|
||||
{
|
||||
"title": payload.get("title") or "Remote action",
|
||||
"status": "failed",
|
||||
"message": reason,
|
||||
"ok": False,
|
||||
"finished_at": finished,
|
||||
"started_at": started,
|
||||
"target": payload.get("target") or host.hostname,
|
||||
}
|
||||
)
|
||||
host.remote_action = payload
|
||||
host.agent_update_state = "failed"
|
||||
host.agent_update_last_error = reason[:2000]
|
||||
hostnames.append(host.hostname or str(host.id))
|
||||
|
||||
db.commit()
|
||||
with _lock:
|
||||
for host in rows:
|
||||
_running.discard(int(host.id))
|
||||
return hostnames
|
||||
|
||||
|
||||
def cancel_host_remote_action(db: Session, host: Host, *, reason: str | None = None) -> bool:
|
||||
"""Отменить зависший running job вручную (admin)."""
|
||||
if host.agent_update_state != "running":
|
||||
return False
|
||||
msg = reason or "Отменено администратором SAC"
|
||||
cleared = clear_stale_running_remote_actions(db, reason=msg, host_ids=[int(host.id)])
|
||||
return bool(cleared)
|
||||
|
||||
|
||||
def get_remote_action_status(host: Host) -> dict[str, Any]:
|
||||
payload = dict(host.remote_action or {})
|
||||
if not payload:
|
||||
return {"active": False, "host_id": host.id}
|
||||
agent_state = (host.agent_update_state or "").strip().lower()
|
||||
if agent_state == "running":
|
||||
active = True
|
||||
status = payload.get("status") or "running"
|
||||
elif agent_state in ("success", "failed"):
|
||||
active = False
|
||||
status = payload.get("status") or agent_state
|
||||
else:
|
||||
status = payload.get("status") or host.agent_update_state or "unknown"
|
||||
active = status == "running"
|
||||
return {
|
||||
"active": active,
|
||||
"host_id": host.id,
|
||||
"hostname": host.hostname,
|
||||
"status": status,
|
||||
"title": payload.get("title") or "",
|
||||
"message": payload.get("message") or "",
|
||||
"stdout": payload.get("stdout") or "",
|
||||
"stderr": payload.get("stderr") or "",
|
||||
"output": payload.get("output") or "",
|
||||
"ok": payload.get("ok"),
|
||||
"exit_code": payload.get("exit_code"),
|
||||
"product_version": payload.get("product_version") or host.product_version,
|
||||
"target": payload.get("target") or host.hostname,
|
||||
"agent_update_state": host.agent_update_state,
|
||||
"started_at": payload.get("started_at"),
|
||||
"finished_at": payload.get("finished_at"),
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Live user sessions on hosts (Linux loginctl / Windows qwinsta) via SSH or WinRM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.event_actor_user import extract_event_actor_user
|
||||
from app.services.linux_admin_settings import LinuxAdminConfig
|
||||
from app.services.ssh_connect import (
|
||||
HostNotLinuxError as SshHostNotLinuxError,
|
||||
HostTargetMissingError as SshHostTargetMissingError,
|
||||
SshCommandResult,
|
||||
is_linux_host,
|
||||
iter_ssh_targets,
|
||||
run_ssh_command,
|
||||
)
|
||||
from app.services.win_admin_settings import WinAdminConfig
|
||||
from app.services.winrm_connect import (
|
||||
HostNotWindowsError,
|
||||
HostTargetMissingError,
|
||||
WinRmCmdResult,
|
||||
is_windows_host,
|
||||
run_winrm_logoff,
|
||||
run_winrm_on_host_targets,
|
||||
run_winrm_qwinsta,
|
||||
)
|
||||
|
||||
SESSION_EVENT_TYPES_LINUX = frozenset(
|
||||
{
|
||||
"session.logind.new",
|
||||
"ssh.login.success",
|
||||
"privilege.sudo.command",
|
||||
}
|
||||
)
|
||||
SESSION_EVENT_TYPES_WINDOWS = frozenset({"rdp.login.success"})
|
||||
|
||||
SESSION_TERMINATED_AT_KEY = "session_terminated_at"
|
||||
SESSION_TERMINATED_BY_KEY = "session_terminated_by"
|
||||
|
||||
_LOGIND_SESSION_ID_RE = re.compile(r"^\d+$|^c\d+$", re.IGNORECASE)
|
||||
_LOGIND_USER_RE = re.compile(r"^[a-z_][a-z0-9._-]*$", re.IGNORECASE)
|
||||
_LOGIND_STATES = frozenset(
|
||||
{
|
||||
"active",
|
||||
"online",
|
||||
"closing",
|
||||
"opening",
|
||||
"degraded",
|
||||
"lingering",
|
||||
"preparing",
|
||||
"unknown",
|
||||
}
|
||||
)
|
||||
_LOGIND_TTY_RE = re.compile(r"^(pts|tty)/", re.IGNORECASE)
|
||||
_NO_SESSION_MARKERS = ("no session", "unknown session", "does not exist")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostSessionRow:
|
||||
session_id: str
|
||||
user: str
|
||||
tty: str | None = None
|
||||
state: str | None = None
|
||||
source_ip: str | None = None
|
||||
session_name: str | None = None
|
||||
|
||||
|
||||
def _details_dict(event: Event) -> dict:
|
||||
raw = event.details
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def _event_login_user(event: Event) -> str:
|
||||
actor = extract_event_actor_user(event.type, event.details)
|
||||
if actor:
|
||||
return actor.strip()
|
||||
return str(_details_dict(event).get("user") or "").strip()
|
||||
|
||||
|
||||
def event_session_terminated(event: Event, db: Session | None = None) -> bool:
|
||||
from app.services.rdp_session_logoff import (
|
||||
event_closed_by_logoff,
|
||||
resolve_workstation_login_closed_by_logoff,
|
||||
)
|
||||
from app.services.rdg_workstation_session import (
|
||||
event_closed_by_rdg,
|
||||
resolve_workstation_login_closed,
|
||||
)
|
||||
|
||||
details = _details_dict(event)
|
||||
if details.get("session_terminated") is True:
|
||||
return True
|
||||
at = details.get(SESSION_TERMINATED_AT_KEY)
|
||||
if at is not None and str(at).strip() != "":
|
||||
return True
|
||||
if event_closed_by_rdg(event):
|
||||
return True
|
||||
if event_closed_by_logoff(event):
|
||||
return True
|
||||
if db is not None and event.type == "rdp.login.success":
|
||||
if resolve_workstation_login_closed_by_logoff(db, event):
|
||||
return True
|
||||
return resolve_workstation_login_closed(db, event)
|
||||
return False
|
||||
|
||||
|
||||
def mark_event_session_terminated(event: Event, *, by_username: str | None = None) -> None:
|
||||
details = dict(_details_dict(event))
|
||||
details[SESSION_TERMINATED_AT_KEY] = datetime.now(timezone.utc).isoformat()
|
||||
if by_username and by_username.strip():
|
||||
details[SESSION_TERMINATED_BY_KEY] = by_username.strip()
|
||||
event.details = details
|
||||
|
||||
|
||||
def event_session_id(event: Event) -> str | None:
|
||||
details = _details_dict(event)
|
||||
for key in ("session_id", "sid"):
|
||||
val = details.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
return str(val).strip()
|
||||
return None
|
||||
|
||||
|
||||
def event_supports_session_terminate(event: Event) -> bool:
|
||||
if event.type in SESSION_EVENT_TYPES_LINUX:
|
||||
return is_linux_host_event(event)
|
||||
if event.type in SESSION_EVENT_TYPES_WINDOWS:
|
||||
return is_windows_host_event(event)
|
||||
return False
|
||||
|
||||
|
||||
def is_linux_host_event(event: Event) -> bool:
|
||||
host = event.host
|
||||
return host is not None and is_linux_host(host)
|
||||
|
||||
|
||||
def is_windows_host_event(event: Event) -> bool:
|
||||
host = event.host
|
||||
return host is not None and is_windows_host(host)
|
||||
|
||||
|
||||
def _normalize_logind_tty(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text or text == "-":
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _logind_row_priority(row: HostSessionRow) -> tuple[int, int]:
|
||||
"""Prefer interactive TTY sessions over ephemeral scope/SSH-exec rows (tty «-»)."""
|
||||
has_tty = 1 if row.tty and _LOGIND_TTY_RE.match(row.tty) else 0
|
||||
try:
|
||||
sid_num = int(row.session_id)
|
||||
except ValueError:
|
||||
sid_num = 0
|
||||
return (has_tty, sid_num)
|
||||
|
||||
|
||||
def filter_logind_session_rows(rows: list[HostSessionRow]) -> list[HostSessionRow]:
|
||||
"""Drop no-TTY duplicates when the same user already has a pts/tty session."""
|
||||
if len(rows) < 2:
|
||||
return rows
|
||||
|
||||
by_user: dict[str, list[HostSessionRow]] = {}
|
||||
for row in rows:
|
||||
key = row.user.casefold()
|
||||
by_user.setdefault(key, []).append(row)
|
||||
|
||||
out: list[HostSessionRow] = []
|
||||
for group in by_user.values():
|
||||
if len(group) == 1:
|
||||
out.append(group[0])
|
||||
continue
|
||||
with_tty = [r for r in group if r.tty and _LOGIND_TTY_RE.match(r.tty)]
|
||||
if with_tty:
|
||||
out.append(max(with_tty, key=_logind_row_priority))
|
||||
continue
|
||||
out.append(max(group, key=_logind_row_priority))
|
||||
out.sort(key=lambda r: (r.user.casefold(), -_logind_row_priority(r)[1]))
|
||||
return out
|
||||
|
||||
|
||||
def parse_loginctl_sessions_json(stdout: str) -> list[HostSessionRow]:
|
||||
text = stdout.strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(payload, list):
|
||||
return []
|
||||
|
||||
rows: list[HostSessionRow] = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
sid = str(item.get("session") or "").strip()
|
||||
user = str(item.get("user") or "").strip()
|
||||
if not sid or not user:
|
||||
continue
|
||||
if not _LOGIND_SESSION_ID_RE.match(sid):
|
||||
continue
|
||||
if not _LOGIND_USER_RE.match(user):
|
||||
continue
|
||||
state = str(item.get("state") or "").strip().lower() or None
|
||||
if state and state not in _LOGIND_STATES:
|
||||
continue
|
||||
rows.append(
|
||||
HostSessionRow(
|
||||
session_id=sid,
|
||||
user=user,
|
||||
tty=_normalize_logind_tty(item.get("tty")),
|
||||
state=state,
|
||||
)
|
||||
)
|
||||
return filter_logind_session_rows(rows)
|
||||
|
||||
|
||||
def _looks_like_loginctl_row(parts: list[str]) -> bool:
|
||||
if len(parts) < 6:
|
||||
return False
|
||||
sid, uid, user, state = parts[0], parts[1], parts[2], parts[5].lower()
|
||||
if not _LOGIND_SESSION_ID_RE.match(sid):
|
||||
return False
|
||||
if not uid.isdigit():
|
||||
return False
|
||||
if not _LOGIND_USER_RE.match(user):
|
||||
return False
|
||||
return state in _LOGIND_STATES
|
||||
|
||||
|
||||
def parse_loginctl_sessions(stdout: str) -> list[HostSessionRow]:
|
||||
rows: list[HostSessionRow] = []
|
||||
for line in stdout.splitlines():
|
||||
text = line.strip()
|
||||
if not text or text.startswith("SESSION"):
|
||||
continue
|
||||
parts = text.split()
|
||||
if not _looks_like_loginctl_row(parts):
|
||||
continue
|
||||
sid, user = parts[0], parts[2]
|
||||
tty = parts[4] if len(parts) > 4 and parts[4] != "-" else None
|
||||
state = parts[5] if len(parts) > 5 else None
|
||||
rows.append(
|
||||
HostSessionRow(
|
||||
session_id=sid,
|
||||
user=user,
|
||||
tty=_normalize_logind_tty(tty),
|
||||
state=state,
|
||||
)
|
||||
)
|
||||
return filter_logind_session_rows(rows)
|
||||
|
||||
|
||||
def _shell_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _linux_session_exists_message(stderr: str, stdout: str) -> bool:
|
||||
blob = f"{stderr}\n{stdout}".casefold()
|
||||
return any(marker in blob for marker in _NO_SESSION_MARKERS)
|
||||
|
||||
|
||||
def _linux_show_session_user(host: Host, cfg: LinuxAdminConfig, session_id: str) -> str | None:
|
||||
sid = session_id.strip()
|
||||
if not sid:
|
||||
return None
|
||||
remote_cmd = f"loginctl show-session {_shell_quote(sid)} -p Name --value"
|
||||
targets = iter_ssh_targets(host)
|
||||
for target in targets:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
remote_cmd=remote_cmd,
|
||||
connect_timeout_sec=15,
|
||||
command_timeout_sec=30,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
if not result.ok:
|
||||
continue
|
||||
user = result.stdout.strip().splitlines()[-1].strip() if result.stdout.strip() else ""
|
||||
if user and _LOGIND_USER_RE.match(user):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
def list_linux_sessions(host: Host, cfg: LinuxAdminConfig) -> tuple[list[HostSessionRow], SshCommandResult]:
|
||||
if not is_linux_host(host):
|
||||
raise SshHostNotLinuxError("Host is not Linux")
|
||||
targets = iter_ssh_targets(host)
|
||||
json_cmd = "loginctl list-sessions --output=json --no-legend"
|
||||
text_cmd = "loginctl list-sessions --no-legend --no-pager"
|
||||
last: SshCommandResult | None = None
|
||||
for target in targets:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
remote_cmd=json_cmd,
|
||||
connect_timeout_sec=15,
|
||||
command_timeout_sec=45,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
last = result
|
||||
if result.ok and result.stdout.strip():
|
||||
rows = parse_loginctl_sessions_json(result.stdout)
|
||||
if rows:
|
||||
return rows, result
|
||||
rows = parse_loginctl_sessions(result.stdout)
|
||||
if rows:
|
||||
return rows, result
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
remote_cmd=text_cmd,
|
||||
connect_timeout_sec=15,
|
||||
command_timeout_sec=45,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
last = result
|
||||
if result.ok and result.stdout.strip():
|
||||
return parse_loginctl_sessions(result.stdout), result
|
||||
assert last is not None
|
||||
return [], last
|
||||
|
||||
|
||||
def terminate_linux_session(
|
||||
host: Host,
|
||||
cfg: LinuxAdminConfig,
|
||||
session_id: str,
|
||||
) -> SshCommandResult:
|
||||
sid = session_id.strip()
|
||||
if not sid:
|
||||
return SshCommandResult(ok=False, message="session_id is required", target="")
|
||||
if not is_linux_host(host):
|
||||
raise SshHostNotLinuxError("Host is not Linux")
|
||||
targets = iter_ssh_targets(host)
|
||||
remote_cmd = f"loginctl terminate-session {_shell_quote(sid)}"
|
||||
last: SshCommandResult | None = None
|
||||
session_user = _linux_show_session_user(host, cfg, sid)
|
||||
for target in targets:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
remote_cmd=remote_cmd,
|
||||
connect_timeout_sec=15,
|
||||
command_timeout_sec=45,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
last = result
|
||||
if result.ok:
|
||||
return result
|
||||
if session_user:
|
||||
return _terminate_linux_user_sessions(host, cfg, session_user)
|
||||
if last is not None and _linux_session_exists_message(last.stderr, last.stdout):
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=(
|
||||
f"Сессия {sid} не найдена (устарела). Обновите список и повторите "
|
||||
"или завершите все сессии пользователя."
|
||||
),
|
||||
target=last.target,
|
||||
stdout=last.stdout,
|
||||
stderr=last.stderr,
|
||||
exit_code=last.exit_code,
|
||||
)
|
||||
assert last is not None
|
||||
return last
|
||||
|
||||
|
||||
def _normalize_sam_account(user: str) -> str:
|
||||
text = (user or "").strip()
|
||||
if "\\" in text:
|
||||
return text.split("\\")[-1].strip().casefold()
|
||||
if "@" in text:
|
||||
return text.split("@")[0].strip().casefold()
|
||||
return text.casefold()
|
||||
|
||||
|
||||
def windows_user_matches_session(login_user: str, session_user: str) -> bool:
|
||||
login = _normalize_sam_account(login_user)
|
||||
session = _normalize_sam_account(session_user)
|
||||
return bool(login and session and login == session)
|
||||
|
||||
|
||||
def filter_windows_sessions_for_user(
|
||||
sessions: list[HostSessionRow],
|
||||
login_user: str,
|
||||
) -> list[HostSessionRow]:
|
||||
user = (login_user or "").strip()
|
||||
if not user:
|
||||
return sessions
|
||||
return [s for s in sessions if windows_user_matches_session(user, s.user)]
|
||||
|
||||
|
||||
def _qwinsta_sam_account(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if "\\" in text:
|
||||
text = text.split("\\")[-1]
|
||||
if "@" in text:
|
||||
text = text.split("@")[0]
|
||||
return text.casefold()
|
||||
|
||||
|
||||
def parse_qwinsta_sessions(stdout: str, *, filter_user: str | None = None) -> list[HostSessionRow]:
|
||||
"""Parse ``qwinsta`` output.
|
||||
|
||||
Disconnected sessions often have an empty SESSIONNAME column, so the line
|
||||
becomes ``USERNAME ID STATE`` (3 tokens). Older parsing required 4 tokens
|
||||
and skipped those rows — that broke RDG flap auto-logoff for Disc sessions.
|
||||
"""
|
||||
rows: list[HostSessionRow] = []
|
||||
filter_sam = _qwinsta_sam_account(filter_user or "")
|
||||
skip_users = frozenset({"services"})
|
||||
|
||||
for line in stdout.splitlines():
|
||||
text = line.strip()
|
||||
if not text or re.match(r"^SESSION", text, re.I) or text.startswith("---"):
|
||||
continue
|
||||
parts = text.split()
|
||||
id_idx: int | None = None
|
||||
sid = 0
|
||||
for i, part in enumerate(parts):
|
||||
token = part.lstrip(">")
|
||||
if token.isdigit():
|
||||
id_idx = i
|
||||
sid = int(token)
|
||||
break
|
||||
if id_idx is None or id_idx < 1:
|
||||
continue
|
||||
state = " ".join(parts[id_idx + 1 :]) if id_idx + 1 < len(parts) else ""
|
||||
if not state or state.casefold().startswith("listen"):
|
||||
continue
|
||||
before = parts[:id_idx]
|
||||
if len(before) == 1:
|
||||
session_name = ""
|
||||
user_name = before[0].lstrip(">")
|
||||
else:
|
||||
session_name = before[0].lstrip(">")
|
||||
user_name = before[1]
|
||||
if user_name.casefold() in skip_users:
|
||||
continue
|
||||
if filter_sam and filter_sam not in _qwinsta_sam_account(user_name):
|
||||
continue
|
||||
rows.append(
|
||||
HostSessionRow(
|
||||
session_id=str(sid),
|
||||
user=user_name,
|
||||
session_name=session_name,
|
||||
state=state,
|
||||
)
|
||||
)
|
||||
|
||||
if rows or not filter_sam:
|
||||
return rows
|
||||
|
||||
return parse_qwinsta_sessions(stdout, filter_user=None)
|
||||
|
||||
|
||||
def list_windows_sessions(host: Host, cfg: WinAdminConfig) -> tuple[list[HostSessionRow], WinRmCmdResult | None]:
|
||||
if not is_windows_host(host):
|
||||
raise HostNotWindowsError("Host is not Windows")
|
||||
|
||||
def action(target: str) -> WinRmCmdResult:
|
||||
return run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password)
|
||||
|
||||
result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action)
|
||||
if result is None or not result.ok:
|
||||
return [], result
|
||||
return parse_qwinsta_sessions(result.stdout), result
|
||||
|
||||
|
||||
def terminate_windows_session(
|
||||
host: Host,
|
||||
cfg: WinAdminConfig,
|
||||
session_id: str,
|
||||
) -> WinRmCmdResult | None:
|
||||
try:
|
||||
sid = int(session_id.strip())
|
||||
except ValueError:
|
||||
return WinRmCmdResult(ok=False, message="Invalid Windows session_id", target="")
|
||||
|
||||
def action(target: str) -> WinRmCmdResult:
|
||||
return run_winrm_logoff(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
session_id=sid,
|
||||
)
|
||||
|
||||
result, _attempts = run_winrm_on_host_targets(host, user=cfg.user, password=cfg.password, action=action)
|
||||
return result
|
||||
|
||||
|
||||
def terminate_session_for_event(
|
||||
event: Event,
|
||||
*,
|
||||
linux_cfg: LinuxAdminConfig,
|
||||
win_cfg: WinAdminConfig,
|
||||
session_id: str | None = None,
|
||||
) -> SshCommandResult | WinRmCmdResult:
|
||||
host = event.host
|
||||
if host is None:
|
||||
raise ValueError("Event has no host")
|
||||
|
||||
sid = (session_id or event_session_id(event) or "").strip()
|
||||
if is_linux_host(host):
|
||||
if not sid:
|
||||
user = _event_login_user(event)
|
||||
if user:
|
||||
return _terminate_linux_user_sessions(host, linux_cfg, user)
|
||||
raise ValueError("session_id is required for this event")
|
||||
return terminate_linux_session(host, linux_cfg, sid)
|
||||
|
||||
if is_windows_host(host):
|
||||
if not sid:
|
||||
user = _event_login_user(event)
|
||||
sessions, qwinsta = list_windows_sessions(host, win_cfg)
|
||||
if not qwinsta or not qwinsta.ok:
|
||||
raise ValueError(qwinsta.message if qwinsta else "qwinsta failed")
|
||||
matched = filter_windows_sessions_for_user(sessions, user) if user else sessions
|
||||
if len(matched) == 1:
|
||||
sid = matched[0].session_id
|
||||
elif not matched:
|
||||
# Пользователь уже вышел из RDP — qwinsta пуст, событие входа в SAC ещё «открыто».
|
||||
return WinRmCmdResult(
|
||||
ok=True,
|
||||
message="На хосте нет активной сессии пользователя (уже вышел из RDP)",
|
||||
target=qwinsta.target,
|
||||
stdout=qwinsta.stdout,
|
||||
)
|
||||
else:
|
||||
raise ValueError("Multiple sessions; specify session_id")
|
||||
result = terminate_windows_session(host, win_cfg, sid)
|
||||
if result is None:
|
||||
raise ValueError("WinRM logoff failed")
|
||||
return result
|
||||
|
||||
raise ValueError("Unsupported host OS for session terminate")
|
||||
|
||||
|
||||
def _terminate_linux_user_sessions(host: Host, cfg: LinuxAdminConfig, user: str) -> SshCommandResult:
|
||||
safe_user = _shell_quote(user.strip())
|
||||
remote_cmd = f"loginctl terminate-user {safe_user}"
|
||||
targets = iter_ssh_targets(host)
|
||||
last: SshCommandResult | None = None
|
||||
for target in targets:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
remote_cmd=remote_cmd,
|
||||
connect_timeout_sec=15,
|
||||
command_timeout_sec=45,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
last = result
|
||||
if result.ok:
|
||||
return result
|
||||
assert last is not None
|
||||
return last
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Периодическое сканирование хостов с устаревшим agent.heartbeat (proactive host_silence)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Host, Problem
|
||||
from app.services.problem_rules import RULE_HOST_SILENCE, RuleMatch, build_fingerprint, host_silence_match_for_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTIVE_HOST_SILENCE_STATUSES = ("open", "acknowledged")
|
||||
_HOST_SILENCE_SCAN_LOCK_KEY = 915_001_001
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostSilenceScanResult:
|
||||
host_id: int
|
||||
hostname: str
|
||||
problem: Problem
|
||||
created: bool
|
||||
|
||||
|
||||
def _host_silence_scan_lock(db: Session) -> bool:
|
||||
"""Один scan за раз (uvicorn workers + systemd timer)."""
|
||||
bind = db.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
return True
|
||||
acquired = db.execute(
|
||||
text("SELECT pg_try_advisory_xact_lock(:key)"),
|
||||
{"key": _HOST_SILENCE_SCAN_LOCK_KEY},
|
||||
).scalar()
|
||||
return bool(acquired)
|
||||
|
||||
|
||||
def _find_active_host_silence_problem(
|
||||
db: Session,
|
||||
*,
|
||||
host_id: int,
|
||||
hostname: str | None,
|
||||
) -> Problem | None:
|
||||
active = db.scalar(
|
||||
select(Problem)
|
||||
.where(
|
||||
Problem.host_id == host_id,
|
||||
Problem.rule_id == RULE_HOST_SILENCE,
|
||||
Problem.status.in_(ACTIVE_HOST_SILENCE_STATUSES),
|
||||
)
|
||||
.order_by(Problem.last_seen_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if active is not None:
|
||||
return active
|
||||
|
||||
name = (hostname or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
sibling = db.scalar(
|
||||
select(Problem)
|
||||
.join(Host, Problem.host_id == Host.id)
|
||||
.where(
|
||||
Problem.rule_id == RULE_HOST_SILENCE,
|
||||
Problem.status.in_(ACTIVE_HOST_SILENCE_STATUSES),
|
||||
func.lower(Host.hostname) == name.lower(),
|
||||
)
|
||||
.order_by(Problem.last_seen_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if sibling is not None and sibling.host_id != host_id:
|
||||
sibling.host_id = host_id
|
||||
db.flush()
|
||||
return sibling
|
||||
|
||||
|
||||
def _apply_host_silence_refresh(
|
||||
problem: Problem,
|
||||
*,
|
||||
host_id: int,
|
||||
match: RuleMatch,
|
||||
fingerprint: str,
|
||||
ref: datetime,
|
||||
) -> None:
|
||||
problem.host_id = host_id
|
||||
problem.summary = match.summary
|
||||
problem.title = match.title
|
||||
problem.fingerprint = fingerprint
|
||||
problem.last_seen_at = ref
|
||||
problem.updated_at = ref
|
||||
|
||||
|
||||
def open_or_refresh_host_silence_problem(
|
||||
db: Session,
|
||||
host_id: int,
|
||||
match: RuleMatch,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
hostname: str | None = None,
|
||||
) -> tuple[Problem | None, bool]:
|
||||
"""
|
||||
Открыть или обновить open/ack Problem host_silence без ingest-события.
|
||||
|
||||
Returns (problem, created). problem is None when suppressed by manual-resolve cooldown.
|
||||
"""
|
||||
ref = now or datetime.now(timezone.utc)
|
||||
if hostname is None:
|
||||
host = db.get(Host, host_id)
|
||||
hostname = host.hostname if host is not None else None
|
||||
|
||||
fingerprint = build_fingerprint(
|
||||
host_id,
|
||||
match.correlation_type,
|
||||
match.rule_id,
|
||||
match.fingerprint_suffix,
|
||||
)
|
||||
|
||||
active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
|
||||
if active_problem is not None:
|
||||
_apply_host_silence_refresh(
|
||||
active_problem,
|
||||
host_id=host_id,
|
||||
match=match,
|
||||
fingerprint=fingerprint,
|
||||
ref=ref,
|
||||
)
|
||||
db.flush()
|
||||
return active_problem, False
|
||||
|
||||
settings = get_settings()
|
||||
cooldown_hours = settings.sac_host_silence_manual_resolve_cooldown_hours
|
||||
if cooldown_hours > 0:
|
||||
cooldown = timedelta(hours=cooldown_hours)
|
||||
manual_recent = db.scalar(
|
||||
select(Problem)
|
||||
.where(
|
||||
Problem.host_id == host_id,
|
||||
Problem.rule_id == match.rule_id,
|
||||
Problem.fingerprint == fingerprint,
|
||||
Problem.status == "resolved",
|
||||
Problem.resolved_by == "manual",
|
||||
Problem.updated_at >= ref - cooldown,
|
||||
)
|
||||
.order_by(Problem.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if manual_recent is not None:
|
||||
logger.debug(
|
||||
"host_silence suppressed (manual cooldown %sh) host_id=%s problem_id=%s",
|
||||
cooldown_hours,
|
||||
host_id,
|
||||
manual_recent.id,
|
||||
)
|
||||
return None, False
|
||||
|
||||
manual_expired = db.scalar(
|
||||
select(Problem)
|
||||
.where(
|
||||
Problem.host_id == host_id,
|
||||
Problem.rule_id == match.rule_id,
|
||||
Problem.fingerprint == fingerprint,
|
||||
Problem.status == "resolved",
|
||||
Problem.resolved_by == "manual",
|
||||
Problem.updated_at < ref - cooldown,
|
||||
)
|
||||
.order_by(Problem.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if manual_expired is not None:
|
||||
active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
|
||||
if active_problem is not None:
|
||||
_apply_host_silence_refresh(
|
||||
active_problem,
|
||||
host_id=host_id,
|
||||
match=match,
|
||||
fingerprint=fingerprint,
|
||||
ref=ref,
|
||||
)
|
||||
db.flush()
|
||||
return active_problem, False
|
||||
|
||||
manual_expired.status = "open"
|
||||
manual_expired.resolved_by = None
|
||||
_apply_host_silence_refresh(
|
||||
manual_expired,
|
||||
host_id=host_id,
|
||||
match=match,
|
||||
fingerprint=fingerprint,
|
||||
ref=ref,
|
||||
)
|
||||
db.flush()
|
||||
return manual_expired, True
|
||||
|
||||
problem = Problem(
|
||||
host_id=host_id,
|
||||
title=match.title,
|
||||
summary=match.summary,
|
||||
severity=match.severity,
|
||||
status="open",
|
||||
rule_id=match.rule_id,
|
||||
fingerprint=fingerprint,
|
||||
event_count=0,
|
||||
last_seen_at=ref,
|
||||
)
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.add(problem)
|
||||
db.flush()
|
||||
except IntegrityError:
|
||||
active_problem = _find_active_host_silence_problem(db, host_id=host_id, hostname=hostname)
|
||||
if active_problem is None:
|
||||
raise
|
||||
_apply_host_silence_refresh(
|
||||
active_problem,
|
||||
host_id=host_id,
|
||||
match=match,
|
||||
fingerprint=fingerprint,
|
||||
ref=ref,
|
||||
)
|
||||
db.flush()
|
||||
return active_problem, False
|
||||
return problem, True
|
||||
|
||||
|
||||
def run_host_silence_scan(db: Session, *, now: datetime | None = None) -> list[HostSilenceScanResult]:
|
||||
"""Найти stale-хосты и создать/обновить Problems rule:host_silence."""
|
||||
if not _host_silence_scan_lock(db):
|
||||
logger.debug("host_silence scan skipped (another runner holds advisory lock)")
|
||||
return []
|
||||
|
||||
ref = now or datetime.now(timezone.utc)
|
||||
hosts = db.scalars(select(Host).order_by(Host.id)).all()
|
||||
results: list[HostSilenceScanResult] = []
|
||||
|
||||
for host in hosts:
|
||||
match = host_silence_match_for_host(db, host.id, hostname=host.hostname, now=ref)
|
||||
if match is None:
|
||||
continue
|
||||
problem, created = open_or_refresh_host_silence_problem(
|
||||
db,
|
||||
host.id,
|
||||
match,
|
||||
now=ref,
|
||||
hostname=host.hostname,
|
||||
)
|
||||
if problem is None:
|
||||
continue
|
||||
results.append(
|
||||
HostSilenceScanResult(
|
||||
host_id=host.id,
|
||||
hostname=host.hostname,
|
||||
problem=problem,
|
||||
created=created,
|
||||
)
|
||||
)
|
||||
if created:
|
||||
logger.info(
|
||||
"host_silence scan: new problem host=%s id=%s problem_id=%s",
|
||||
host.hostname,
|
||||
host.id,
|
||||
problem.id,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def dispatch_host_silence_notifications(db: Session, results: list[HostSilenceScanResult]) -> int:
|
||||
"""Telegram/webhook/email только для вновь созданных Problems."""
|
||||
from app.services.notify_dispatch import notify_problem
|
||||
|
||||
sent = 0
|
||||
for item in results:
|
||||
if not item.created:
|
||||
continue
|
||||
try:
|
||||
notify_problem(item.problem, event=None, db=db)
|
||||
sent += 1
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"host_silence notify failed host=%s problem_id=%s",
|
||||
item.hostname,
|
||||
item.problem.id,
|
||||
)
|
||||
return sent
|
||||
@@ -0,0 +1,142 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.agent_update import process_agent_update_ingest
|
||||
from app.services.daily_report_format import normalize_daily_report_details
|
||||
from app.services.event_severity_overrides import apply_severity_override
|
||||
from app.services.host_inventory import INVENTORY_EVENT_TYPE, process_inventory_ingest
|
||||
from app.services.rdp_session_logoff import close_workstation_session_for_rdp_logoff
|
||||
from app.services.rdg_workstation_session import close_workstation_session_for_rdg_end
|
||||
|
||||
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> datetime:
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def upsert_host(db: Session, payload: dict) -> Host:
|
||||
source = payload.get("source") or {}
|
||||
host_data = payload.get("host") or {}
|
||||
agent_id = source.get("agent_instance_id")
|
||||
hostname = host_data.get("hostname") or "unknown"
|
||||
product = source.get("product") or "unknown"
|
||||
|
||||
host: Host | None = None
|
||||
if agent_id:
|
||||
host = db.scalar(select(Host).where(Host.agent_instance_id == agent_id))
|
||||
|
||||
if host is None:
|
||||
host = db.scalar(
|
||||
select(Host).where(Host.hostname == hostname, Host.product == product).limit(1)
|
||||
)
|
||||
|
||||
if host is None:
|
||||
host = Host(
|
||||
agent_instance_id=agent_id,
|
||||
hostname=hostname,
|
||||
display_name=host_data.get("display_name"),
|
||||
os_family=host_data.get("os_family", "linux"),
|
||||
os_version=host_data.get("os_version"),
|
||||
product=product,
|
||||
product_version=source.get("product_version"),
|
||||
ipv4=host_data.get("ipv4"),
|
||||
ipv6=host_data.get("ipv6"),
|
||||
tags=payload.get("tags") or [],
|
||||
)
|
||||
db.add(host)
|
||||
else:
|
||||
host.hostname = hostname
|
||||
dn = host_data.get("display_name")
|
||||
if isinstance(dn, str) and dn.strip():
|
||||
host.display_name = dn.strip()
|
||||
host.os_version = host_data.get("os_version") or host.os_version
|
||||
host.product_version = source.get("product_version") or host.product_version
|
||||
host.ipv4 = host_data.get("ipv4") or host.ipv4
|
||||
host.ipv6 = host_data.get("ipv6") or host.ipv6
|
||||
if agent_id and not host.agent_instance_id:
|
||||
host.agent_instance_id = agent_id
|
||||
|
||||
host.last_seen_at = datetime.now(timezone.utc)
|
||||
db.flush()
|
||||
return host
|
||||
|
||||
|
||||
def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
|
||||
"""
|
||||
Returns (event, created).
|
||||
created=False if event_id already exists (idempotent).
|
||||
"""
|
||||
event_id = payload["event_id"]
|
||||
existing = db.scalar(select(Event).where(Event.event_id == event_id))
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
host = upsert_host(db, payload)
|
||||
payload = apply_severity_override(payload, db)
|
||||
details = payload.get("details")
|
||||
if payload.get("type") in DAILY_REPORT_TYPES:
|
||||
details = normalize_daily_report_details(details, host, payload["type"])
|
||||
|
||||
severity = payload["severity"]
|
||||
title = payload["title"]
|
||||
summary = payload["summary"]
|
||||
if payload.get("type") == INVENTORY_EVENT_TYPE:
|
||||
inv_severity, hw_changes, inv_patch = process_inventory_ingest(host, details)
|
||||
severity = inv_severity
|
||||
if inv_patch:
|
||||
merged = dict(details) if isinstance(details, dict) else {}
|
||||
merged.update(inv_patch)
|
||||
details = merged
|
||||
if hw_changes:
|
||||
change_fields = ", ".join(c["field"] for c in hw_changes)
|
||||
title = f"Hardware inventory changed on {host.hostname}"
|
||||
summary = (
|
||||
f"Изменилось железо на {host.display_name or host.hostname}: {change_fields}"
|
||||
)
|
||||
|
||||
event = Event(
|
||||
event_id=event_id,
|
||||
host_id=host.id,
|
||||
occurred_at=_parse_dt(payload["occurred_at"]),
|
||||
category=payload["category"],
|
||||
type=payload["type"],
|
||||
severity=severity,
|
||||
title=title,
|
||||
summary=summary,
|
||||
details=details,
|
||||
raw=payload.get("raw"),
|
||||
dedup_key=payload.get("dedup_key"),
|
||||
correlation_id=payload.get("correlation_id"),
|
||||
payload=payload,
|
||||
)
|
||||
db.add(event)
|
||||
try:
|
||||
db.flush()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raced = db.scalar(select(Event).where(Event.event_id == event_id))
|
||||
if raced is not None:
|
||||
return raced, False
|
||||
raise
|
||||
|
||||
process_agent_update_ingest(db, host, payload.get("type", ""), details)
|
||||
from app.services.rdg_workstation_session import (
|
||||
enrich_empty_login_from_rdg_success,
|
||||
enrich_workstation_login_user_from_rdg,
|
||||
)
|
||||
|
||||
if event.host is None:
|
||||
event.host = host
|
||||
enrich_workstation_login_user_from_rdg(db, event)
|
||||
enrich_empty_login_from_rdg_success(db, event)
|
||||
close_workstation_session_for_rdg_end(db, event)
|
||||
close_workstation_session_for_rdp_logoff(db, event)
|
||||
return event, True
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Effective Linux SSH admin credentials (host → DB → env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.host import Host
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LinuxAdminConfig:
|
||||
user: str
|
||||
password: str
|
||||
source: str # host | 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)
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Rate limit failed SAC UI logins + optional Telegram alert."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.login_attempt import LoginAttempt
|
||||
from app.services.client_ip import client_ip_from_request
|
||||
from app.services.login_security_settings import (
|
||||
get_effective_login_security_config,
|
||||
is_ip_login_whitelisted,
|
||||
)
|
||||
from app.services.telegram_notify import send_telegram_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginRateLimitConfig:
|
||||
max_failures: int
|
||||
window_minutes: int
|
||||
alert_telegram: bool
|
||||
|
||||
|
||||
def get_login_rate_limit_config() -> LoginRateLimitConfig:
|
||||
settings = get_settings()
|
||||
return LoginRateLimitConfig(
|
||||
max_failures=max(1, int(getattr(settings, "sac_login_max_failures", 3) or 3)),
|
||||
window_minutes=max(1, int(getattr(settings, "sac_login_failure_window_minutes", 15) or 15)),
|
||||
alert_telegram=bool(getattr(settings, "sac_login_alert_telegram", True)),
|
||||
)
|
||||
|
||||
|
||||
def _failure_count(db: Session, ip_address: str, *, since: datetime) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(LoginAttempt)
|
||||
.where(
|
||||
LoginAttempt.ip_address == ip_address,
|
||||
LoginAttempt.success.is_(False),
|
||||
LoginAttempt.created_at >= since,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def _alert_already_sent(db: Session, ip_address: str, *, since: datetime) -> bool:
|
||||
"""Avoid spamming Telegram on every subsequent 429."""
|
||||
row = db.scalar(
|
||||
select(LoginAttempt.username)
|
||||
.where(
|
||||
LoginAttempt.ip_address == ip_address,
|
||||
LoginAttempt.success.is_(False),
|
||||
LoginAttempt.created_at >= since,
|
||||
LoginAttempt.username == "__alert_sent__",
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
def _send_login_bruteforce_alert(ip_address: str, username: str, failures: int) -> None:
|
||||
user_hint = username if username else "—"
|
||||
text = (
|
||||
f"⚠️ <b>SAC: подбор пароля UI</b>\n"
|
||||
f"IP: <code>{ip_address}</code>\n"
|
||||
f"Логин: <code>{user_hint}</code>\n"
|
||||
f"Неудачных попыток: {failures}\n"
|
||||
f"Дальнейший вход с этого IP временно заблокирован."
|
||||
)
|
||||
try:
|
||||
send_telegram_text(text, parse_mode="HTML")
|
||||
except Exception:
|
||||
logger.exception("failed to send SAC login brute-force Telegram alert")
|
||||
|
||||
|
||||
def ensure_login_allowed(db: Session, request: Request) -> str:
|
||||
"""Raise 429 if IP exceeded failed login threshold. Returns client IP."""
|
||||
cfg = get_login_rate_limit_config()
|
||||
ip_address = client_ip_from_request(request)
|
||||
sec = get_effective_login_security_config(db)
|
||||
if is_ip_login_whitelisted(ip_address, sec.ip_whitelist):
|
||||
return ip_address
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
|
||||
failures = _failure_count(db, ip_address, since=since)
|
||||
if failures >= cfg.max_failures:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Слишком много неудачных попыток входа. Повторите через {cfg.window_minutes} мин.",
|
||||
)
|
||||
return ip_address
|
||||
|
||||
|
||||
def record_login_success(db: Session, *, ip_address: str, username: str) -> None:
|
||||
db.add(
|
||||
LoginAttempt(
|
||||
ip_address=ip_address,
|
||||
username=username[:64],
|
||||
success=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def record_login_failure(
|
||||
db: Session,
|
||||
*,
|
||||
ip_address: str,
|
||||
username: str,
|
||||
request: Request | None = None,
|
||||
) -> None:
|
||||
del request # reserved
|
||||
sec = get_effective_login_security_config(db)
|
||||
if is_ip_login_whitelisted(ip_address, sec.ip_whitelist):
|
||||
return
|
||||
cfg = get_login_rate_limit_config()
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
|
||||
prior_failures = _failure_count(db, ip_address, since=since)
|
||||
|
||||
db.add(
|
||||
LoginAttempt(
|
||||
ip_address=ip_address,
|
||||
username=username[:64] if username else None,
|
||||
success=False,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
new_failures = prior_failures + 1
|
||||
if new_failures >= cfg.max_failures and cfg.alert_telegram and not _alert_already_sent(db, ip_address, since=since):
|
||||
db.add(
|
||||
LoginAttempt(
|
||||
ip_address=ip_address,
|
||||
username="__alert_sent__",
|
||||
success=False,
|
||||
)
|
||||
)
|
||||
_send_login_bruteforce_alert(ip_address, username, new_failures)
|
||||
@@ -0,0 +1,254 @@
|
||||
"""SAC UI login security: IP whitelist, blocked list, unblock."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.login_attempt import LoginAttempt
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
from app.services.fail2ban_sync import (
|
||||
Fail2banActionResult,
|
||||
list_ssh_banned_ips,
|
||||
sync_fail2ban_ignore_ips,
|
||||
unban_ssh_ip,
|
||||
)
|
||||
|
||||
_IP_RE = re.compile(
|
||||
r"^(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginSecurityConfig:
|
||||
ip_whitelist: tuple[str, ...]
|
||||
sync_fail2ban: bool
|
||||
max_failures: int
|
||||
window_minutes: int
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginBlockEntry:
|
||||
ip_address: str
|
||||
scope: str
|
||||
failure_count: int | None
|
||||
usernames: tuple[str, ...]
|
||||
blocked_until: datetime | None
|
||||
reason: str
|
||||
|
||||
|
||||
def normalize_ip_token(raw: str) -> str | None:
|
||||
text = (raw or "").strip()
|
||||
if not text or text.startswith("#"):
|
||||
return None
|
||||
if _IP_RE.match(text):
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def parse_ip_whitelist_text(text: str) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
tokens = re.split(r"[\s,;]+", text.replace("\n", " "))
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for token in tokens:
|
||||
ip = normalize_ip_token(token)
|
||||
if ip and ip not in seen:
|
||||
seen.add(ip)
|
||||
out.append(ip)
|
||||
return out
|
||||
|
||||
|
||||
def _env_whitelist() -> list[str]:
|
||||
settings = get_settings()
|
||||
raw = (getattr(settings, "sac_login_ip_whitelist", None) or "").strip()
|
||||
return parse_ip_whitelist_text(raw.replace(",", " "))
|
||||
|
||||
|
||||
def _db_whitelist(row: UiSettings | None) -> list[str]:
|
||||
if row is None:
|
||||
return []
|
||||
return parse_ip_whitelist_text(row.login_ip_whitelist or "")
|
||||
|
||||
|
||||
def merge_whitelist(*parts: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for part in parts:
|
||||
for ip in part:
|
||||
if ip not in seen:
|
||||
seen.add(ip)
|
||||
out.append(ip)
|
||||
return out
|
||||
|
||||
|
||||
def get_effective_login_security_config(db: Session) -> LoginSecurityConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_ips = _env_whitelist()
|
||||
db_ips = _db_whitelist(row)
|
||||
merged = merge_whitelist(env_ips, db_ips)
|
||||
settings = get_settings()
|
||||
max_failures = max(1, int(getattr(settings, "sac_login_max_failures", 3) or 3))
|
||||
window_minutes = max(1, int(getattr(settings, "sac_login_failure_window_minutes", 15) or 15))
|
||||
sources: list[str] = []
|
||||
if env_ips:
|
||||
sources.append("env")
|
||||
if db_ips:
|
||||
sources.append("db")
|
||||
source = "+".join(sources) if sources else "default"
|
||||
sync_fb = bool(row.login_sync_fail2ban) if row is not None else False
|
||||
return LoginSecurityConfig(
|
||||
ip_whitelist=tuple(merged),
|
||||
sync_fail2ban=sync_fb,
|
||||
max_failures=max_failures,
|
||||
window_minutes=window_minutes,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def is_ip_login_whitelisted(ip_address: str, whitelist: tuple[str, ...] | list[str]) -> bool:
|
||||
ip = (ip_address or "").strip()
|
||||
if not ip:
|
||||
return False
|
||||
return ip in whitelist
|
||||
|
||||
|
||||
def upsert_login_security_settings(
|
||||
db: Session,
|
||||
*,
|
||||
ip_whitelist_text: str,
|
||||
sync_fail2ban: bool,
|
||||
) -> LoginSecurityConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
|
||||
db.add(row)
|
||||
row.login_ip_whitelist = ip_whitelist_text.strip() or None
|
||||
row.login_sync_fail2ban = bool(sync_fail2ban)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
cfg = get_effective_login_security_config(db)
|
||||
if cfg.sync_fail2ban:
|
||||
sync_fail2ban_ignore_ips(list(cfg.ip_whitelist))
|
||||
return cfg
|
||||
|
||||
|
||||
def _window_since(cfg: LoginSecurityConfig) -> datetime:
|
||||
return datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
|
||||
|
||||
|
||||
def list_web_login_blocks(db: Session) -> list[LoginBlockEntry]:
|
||||
cfg = get_effective_login_security_config(db)
|
||||
since = _window_since(cfg)
|
||||
rows = db.execute(
|
||||
select(
|
||||
LoginAttempt.ip_address,
|
||||
func.count().label("cnt"),
|
||||
func.max(LoginAttempt.created_at).label("last_at"),
|
||||
)
|
||||
.where(
|
||||
LoginAttempt.success.is_(False),
|
||||
LoginAttempt.username != "__alert_sent__",
|
||||
LoginAttempt.created_at >= since,
|
||||
)
|
||||
.group_by(LoginAttempt.ip_address)
|
||||
.having(func.count() >= cfg.max_failures)
|
||||
).all()
|
||||
|
||||
blocks: list[LoginBlockEntry] = []
|
||||
for ip_address, cnt, last_at in rows:
|
||||
ip = str(ip_address)
|
||||
if is_ip_login_whitelisted(ip, cfg.ip_whitelist):
|
||||
continue
|
||||
user_rows = db.scalars(
|
||||
select(LoginAttempt.username)
|
||||
.where(
|
||||
LoginAttempt.ip_address == ip,
|
||||
LoginAttempt.success.is_(False),
|
||||
LoginAttempt.username.isnot(None),
|
||||
LoginAttempt.username != "__alert_sent__",
|
||||
LoginAttempt.created_at >= since,
|
||||
)
|
||||
.distinct()
|
||||
.limit(10)
|
||||
).all()
|
||||
usernames = tuple(sorted({u for u in user_rows if u}))
|
||||
blocked_until = (last_at + timedelta(minutes=cfg.window_minutes)) if last_at else None
|
||||
blocks.append(
|
||||
LoginBlockEntry(
|
||||
ip_address=ip,
|
||||
scope="web",
|
||||
failure_count=int(cnt or 0),
|
||||
usernames=usernames,
|
||||
blocked_until=blocked_until,
|
||||
reason=f"≥{cfg.max_failures} неудачных входов в UI за {cfg.window_minutes} мин",
|
||||
)
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
def list_ssh_login_blocks() -> list[LoginBlockEntry]:
|
||||
banned = list_ssh_banned_ips()
|
||||
return [
|
||||
LoginBlockEntry(
|
||||
ip_address=ip,
|
||||
scope="ssh",
|
||||
failure_count=None,
|
||||
usernames=(),
|
||||
blocked_until=None,
|
||||
reason="fail2ban (sshd)",
|
||||
)
|
||||
for ip in banned
|
||||
]
|
||||
|
||||
|
||||
def list_all_login_blocks(db: Session) -> list[LoginBlockEntry]:
|
||||
web = {b.ip_address: b for b in list_web_login_blocks(db)}
|
||||
ssh = list_ssh_login_blocks()
|
||||
out = list(web.values())
|
||||
for entry in ssh:
|
||||
if entry.ip_address in web:
|
||||
existing = web[entry.ip_address]
|
||||
out[out.index(existing)] = LoginBlockEntry(
|
||||
ip_address=entry.ip_address,
|
||||
scope="web+ssh",
|
||||
failure_count=existing.failure_count,
|
||||
usernames=existing.usernames,
|
||||
blocked_until=existing.blocked_until,
|
||||
reason=f"{existing.reason}; {entry.reason}",
|
||||
)
|
||||
else:
|
||||
out.append(entry)
|
||||
out.sort(key=lambda e: e.ip_address)
|
||||
return out
|
||||
|
||||
|
||||
def clear_web_login_block(db: Session, ip_address: str) -> int:
|
||||
ip = (ip_address or "").strip()
|
||||
if not ip:
|
||||
return 0
|
||||
result = db.execute(delete(LoginAttempt).where(LoginAttempt.ip_address == ip))
|
||||
db.commit()
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
def unblock_login_ip(db: Session, ip_address: str, *, scope: str = "both") -> list[str]:
|
||||
ip = (ip_address or "").strip()
|
||||
if not ip:
|
||||
return ["empty ip"]
|
||||
messages: list[str] = []
|
||||
scope_norm = (scope or "both").strip().lower()
|
||||
if scope_norm in ("web", "both"):
|
||||
deleted = clear_web_login_block(db, ip)
|
||||
messages.append(f"Web UI: удалено записей login_attempts: {deleted}")
|
||||
if scope_norm in ("ssh", "both"):
|
||||
res: Fail2banActionResult = unban_ssh_ip(ip)
|
||||
messages.append(res.message)
|
||||
return messages
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Управление зарегистрированными устройствами Seaca."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.mobile_device import MobileDevice
|
||||
from app.models.user import User
|
||||
from app.services.mobile_tokens import revoke_refresh_tokens_for_device
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MobileDeviceSummary:
|
||||
id: int
|
||||
user_id: int
|
||||
username: str
|
||||
device_uuid: str
|
||||
display_name: str
|
||||
platform: str
|
||||
app_version: str | None
|
||||
has_fcm_token: bool
|
||||
enrolled_at: datetime
|
||||
last_seen_at: datetime | None
|
||||
revoked_at: datetime | None
|
||||
is_active: bool
|
||||
|
||||
|
||||
def list_mobile_devices(db: Session) -> list[MobileDeviceSummary]:
|
||||
rows = db.scalars(select(MobileDevice).order_by(MobileDevice.enrolled_at.desc())).all()
|
||||
user_ids = {r.user_id for r in rows}
|
||||
usernames: dict[int, str] = {}
|
||||
if user_ids:
|
||||
for user in db.scalars(select(User).where(User.id.in_(user_ids))).all():
|
||||
usernames[user.id] = user.username
|
||||
return [_to_summary(row, usernames.get(row.user_id, "?")) for row in rows]
|
||||
|
||||
|
||||
def _to_summary(row: MobileDevice, username: str) -> MobileDeviceSummary:
|
||||
return MobileDeviceSummary(
|
||||
id=row.id,
|
||||
user_id=row.user_id,
|
||||
username=username,
|
||||
device_uuid=row.device_uuid,
|
||||
display_name=row.display_name or "",
|
||||
platform=row.platform or "android",
|
||||
app_version=row.app_version,
|
||||
has_fcm_token=bool((row.fcm_token or "").strip()),
|
||||
enrolled_at=row.enrolled_at,
|
||||
last_seen_at=row.last_seen_at,
|
||||
revoked_at=row.revoked_at,
|
||||
is_active=row.revoked_at is None,
|
||||
)
|
||||
|
||||
|
||||
def get_device_or_raise(db: Session, device_id: int) -> MobileDevice:
|
||||
row = db.get(MobileDevice, device_id)
|
||||
if row is None:
|
||||
raise ValueError("device not found")
|
||||
return row
|
||||
|
||||
|
||||
def assert_device_active(device: MobileDevice) -> None:
|
||||
if device.revoked_at is not None:
|
||||
raise PermissionError("device has been revoked")
|
||||
|
||||
|
||||
def touch_device(db: Session, device: MobileDevice, *, commit: bool = False) -> None:
|
||||
device.last_seen_at = datetime.now(timezone.utc)
|
||||
if commit:
|
||||
db.commit()
|
||||
|
||||
|
||||
def revoke_device(db: Session, device_id: int) -> MobileDeviceSummary:
|
||||
device = get_device_or_raise(db, device_id)
|
||||
if device.revoked_at is None:
|
||||
device.revoked_at = datetime.now(timezone.utc)
|
||||
revoke_refresh_tokens_for_device(db, device.id)
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
user = db.get(User, device.user_id)
|
||||
username = user.username if user else "?"
|
||||
return _to_summary(device, username)
|
||||
|
||||
|
||||
def delete_device_record(db: Session, device_id: int) -> None:
|
||||
device = get_device_or_raise(db, device_id)
|
||||
revoke_refresh_tokens_for_device(db, device.id)
|
||||
db.delete(device)
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_fcm_token(db: Session, device: MobileDevice, fcm_token: str) -> None:
|
||||
cleaned = (fcm_token or "").strip()
|
||||
if not cleaned:
|
||||
raise ValueError("fcm_token is required")
|
||||
now = datetime.now(timezone.utc)
|
||||
device.fcm_token = cleaned
|
||||
device.fcm_token_updated_at = now
|
||||
device.last_seen_at = now
|
||||
db.commit()
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Создание и проверка кодов регистрации Seaca."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.mobile_device import MobileDevice
|
||||
from app.models.mobile_enrollment_code import MobileEnrollmentCode
|
||||
from app.models.mobile_settings import LOGIN_MODE_CODE_ONLY, LOGIN_MODE_PASSWORD, LOGIN_MODES
|
||||
from app.models.user import User
|
||||
from app.services.mobile_settings import get_effective_mobile_settings
|
||||
from app.services.mobile_tokens import (
|
||||
generate_enrollment_code,
|
||||
hash_mobile_secret,
|
||||
revoke_refresh_tokens_for_device,
|
||||
store_refresh_token,
|
||||
)
|
||||
from app.services.user_auth import authenticate_user
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnrollmentCodeSummary:
|
||||
id: int
|
||||
label: str
|
||||
code_prefix: str
|
||||
target_user_id: int | None
|
||||
target_username: str | None
|
||||
login_mode: str
|
||||
max_uses: int
|
||||
use_count: int
|
||||
expires_at: datetime
|
||||
revoked_at: datetime | None
|
||||
created_at: datetime
|
||||
is_active: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnrollResult:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
device_id: int
|
||||
username: str
|
||||
role: str
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _code_is_usable(row: MobileEnrollmentCode, *, now: datetime) -> bool:
|
||||
if row.revoked_at is not None:
|
||||
return False
|
||||
if _as_utc(row.expires_at) <= now:
|
||||
return False
|
||||
if row.use_count >= row.max_uses:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def list_enrollment_codes(db: Session) -> list[EnrollmentCodeSummary]:
|
||||
rows = db.scalars(
|
||||
select(MobileEnrollmentCode).order_by(MobileEnrollmentCode.created_at.desc())
|
||||
).all()
|
||||
usernames: dict[int, str] = {}
|
||||
user_ids = {r.target_user_id for r in rows if r.target_user_id}
|
||||
if user_ids:
|
||||
for user in db.scalars(select(User).where(User.id.in_(user_ids))).all():
|
||||
usernames[user.id] = user.username
|
||||
now = datetime.now(timezone.utc)
|
||||
return [
|
||||
EnrollmentCodeSummary(
|
||||
id=row.id,
|
||||
label=row.label or "",
|
||||
code_prefix=row.code_prefix,
|
||||
target_user_id=row.target_user_id,
|
||||
target_username=usernames.get(row.target_user_id) if row.target_user_id else None,
|
||||
login_mode=row.login_mode,
|
||||
max_uses=row.max_uses,
|
||||
use_count=row.use_count,
|
||||
expires_at=row.expires_at,
|
||||
revoked_at=row.revoked_at,
|
||||
created_at=row.created_at,
|
||||
is_active=_code_is_usable(row, now=now),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def create_enrollment_code(
|
||||
db: Session,
|
||||
*,
|
||||
created_by_user_id: int | None,
|
||||
label: str,
|
||||
target_user_id: int | None,
|
||||
login_mode: str,
|
||||
max_uses: int,
|
||||
expires_in_hours: int,
|
||||
) -> tuple[EnrollmentCodeSummary, str]:
|
||||
if login_mode not in LOGIN_MODES:
|
||||
raise ValueError(f"login_mode must be one of: {sorted(LOGIN_MODES)}")
|
||||
if login_mode == LOGIN_MODE_CODE_ONLY and target_user_id is None:
|
||||
raise ValueError("code_only requires target_user_id")
|
||||
if max_uses < 1 or max_uses > 100:
|
||||
raise ValueError("max_uses must be between 1 and 100")
|
||||
if expires_in_hours < 1 or expires_in_hours > 24 * 30:
|
||||
raise ValueError("expires_in_hours must be between 1 and 720")
|
||||
|
||||
if target_user_id is not None:
|
||||
user = db.get(User, target_user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise ValueError("target user not found or inactive")
|
||||
|
||||
plaintext, prefix, code_hash = generate_enrollment_code()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)
|
||||
row = MobileEnrollmentCode(
|
||||
label=(label or "").strip(),
|
||||
code_hash=code_hash,
|
||||
code_prefix=prefix,
|
||||
created_by_user_id=created_by_user_id,
|
||||
target_user_id=target_user_id,
|
||||
login_mode=login_mode,
|
||||
max_uses=max_uses,
|
||||
use_count=0,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
target_username = None
|
||||
if row.target_user_id:
|
||||
u = db.get(User, row.target_user_id)
|
||||
target_username = u.username if u else None
|
||||
summary = EnrollmentCodeSummary(
|
||||
id=row.id,
|
||||
label=row.label or "",
|
||||
code_prefix=row.code_prefix,
|
||||
target_user_id=row.target_user_id,
|
||||
target_username=target_username,
|
||||
login_mode=row.login_mode,
|
||||
max_uses=row.max_uses,
|
||||
use_count=row.use_count,
|
||||
expires_at=row.expires_at,
|
||||
revoked_at=row.revoked_at,
|
||||
created_at=row.created_at,
|
||||
is_active=True,
|
||||
)
|
||||
return summary, plaintext
|
||||
|
||||
|
||||
def revoke_enrollment_code(db: Session, code_id: int) -> None:
|
||||
row = db.get(MobileEnrollmentCode, code_id)
|
||||
if row is None:
|
||||
raise ValueError("enrollment code not found")
|
||||
if row.revoked_at is None:
|
||||
row.revoked_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _count_active_devices(db: Session, user_id: int) -> int:
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(MobileDevice)
|
||||
.where(MobileDevice.user_id == user_id, MobileDevice.revoked_at.is_(None))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def enroll_device(
|
||||
db: Session,
|
||||
*,
|
||||
enrollment_code: str,
|
||||
username: str | None,
|
||||
password: str | None,
|
||||
device_uuid: str,
|
||||
display_name: str,
|
||||
platform: str,
|
||||
app_version: str | None,
|
||||
fcm_token: str | None,
|
||||
create_access_token,
|
||||
) -> EnrollResult:
|
||||
from app.auth.jwt_auth import create_access_token as _create_access_token
|
||||
|
||||
token_factory = create_access_token or _create_access_token
|
||||
|
||||
mobile_cfg = get_effective_mobile_settings(db)
|
||||
if not mobile_cfg.devices_allowed:
|
||||
raise PermissionError("mobile enrollment is disabled")
|
||||
|
||||
cleaned_uuid = (device_uuid or "").strip()
|
||||
if len(cleaned_uuid) < 8:
|
||||
raise ValueError("device_uuid is required")
|
||||
|
||||
if mobile_cfg.min_app_version and app_version:
|
||||
if _version_lt(app_version, mobile_cfg.min_app_version):
|
||||
raise ValueError(f"app version {app_version} is below minimum {mobile_cfg.min_app_version}")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
code_hash = hash_mobile_secret(enrollment_code.strip())
|
||||
code_row = db.scalar(
|
||||
select(MobileEnrollmentCode).where(MobileEnrollmentCode.code_hash == code_hash)
|
||||
)
|
||||
if code_row is None or not _code_is_usable(code_row, now=now):
|
||||
raise ValueError("invalid or expired enrollment code")
|
||||
|
||||
if code_row.login_mode == LOGIN_MODE_PASSWORD:
|
||||
if not username or not password:
|
||||
raise ValueError("username and password required")
|
||||
user = authenticate_user(db, username, password)
|
||||
if user is None:
|
||||
raise ValueError("invalid username or password")
|
||||
else:
|
||||
user = db.get(User, code_row.target_user_id) if code_row.target_user_id else None
|
||||
if user is None or not user.is_active:
|
||||
raise ValueError("enrollment code user is invalid")
|
||||
|
||||
if code_row.target_user_id is not None and user.id != code_row.target_user_id:
|
||||
raise ValueError("enrollment code is bound to another user")
|
||||
|
||||
existing = db.scalar(select(MobileDevice).where(MobileDevice.device_uuid == cleaned_uuid))
|
||||
if existing is not None and existing.user_id != user.id:
|
||||
raise ValueError("device_uuid already registered to another user")
|
||||
|
||||
active_count = _count_active_devices(db, user.id)
|
||||
if (
|
||||
existing is not None
|
||||
and existing.user_id == user.id
|
||||
and existing.revoked_at is None
|
||||
):
|
||||
active_count -= 1
|
||||
if active_count >= mobile_cfg.max_devices_per_user:
|
||||
raise ValueError("device limit reached for this user")
|
||||
|
||||
cleaned_display = (display_name or "").strip() or "Android"
|
||||
cleaned_platform = (platform or "android").strip() or "android"
|
||||
cleaned_app_version = (app_version or "").strip() or None
|
||||
cleaned_fcm = (fcm_token or "").strip() or None
|
||||
|
||||
if existing is not None:
|
||||
revoke_refresh_tokens_for_device(db, existing.id)
|
||||
existing.revoked_at = None
|
||||
existing.user_id = user.id
|
||||
existing.display_name = cleaned_display
|
||||
existing.platform = cleaned_platform
|
||||
existing.app_version = cleaned_app_version
|
||||
existing.fcm_token = cleaned_fcm
|
||||
existing.fcm_token_updated_at = now if cleaned_fcm else None
|
||||
existing.enrollment_code_id = code_row.id
|
||||
existing.last_seen_at = now
|
||||
device = existing
|
||||
else:
|
||||
device = MobileDevice(
|
||||
user_id=user.id,
|
||||
device_uuid=cleaned_uuid,
|
||||
display_name=cleaned_display,
|
||||
platform=cleaned_platform,
|
||||
app_version=cleaned_app_version,
|
||||
fcm_token=cleaned_fcm,
|
||||
fcm_token_updated_at=now if cleaned_fcm else None,
|
||||
enrollment_code_id=code_row.id,
|
||||
last_seen_at=now,
|
||||
)
|
||||
db.add(device)
|
||||
code_row.use_count += 1
|
||||
db.flush()
|
||||
|
||||
refresh_plain = store_refresh_token(db, device_id=device.id)
|
||||
access_token = token_factory(user.username, user.role, device_id=device.id)
|
||||
db.commit()
|
||||
|
||||
return EnrollResult(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_plain,
|
||||
device_id=device.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
)
|
||||
|
||||
|
||||
def _version_lt(current: str, minimum: str) -> bool:
|
||||
def parse(v: str) -> tuple[int, ...]:
|
||||
parts: list[int] = []
|
||||
for piece in v.strip().split("."):
|
||||
try:
|
||||
parts.append(int(piece))
|
||||
except ValueError:
|
||||
parts.append(0)
|
||||
return tuple(parts)
|
||||
|
||||
return parse(current) < parse(minimum)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Push-уведомления Seaca через Firebase Cloud Messaging (HTTP v1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Event, Problem
|
||||
from app.models.mobile_device import MobileDevice
|
||||
from app.services.notification_severity import severity_meets_minimum
|
||||
from app.services.notification_policy import get_effective_notification_policy
|
||||
from app.services.telegram_templates import format_event_mobile_push
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
|
||||
|
||||
|
||||
class MobileNotConfiguredError(Exception):
|
||||
"""FCM отключён или не задан service account."""
|
||||
|
||||
|
||||
class MobileSendError(Exception):
|
||||
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FcmConfig:
|
||||
enabled: bool
|
||||
project_id: str
|
||||
service_account_path: str
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.project_id.strip() and self.service_account_path.strip())
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.enabled and self.configured
|
||||
|
||||
|
||||
def get_effective_fcm_config() -> FcmConfig:
|
||||
settings = get_settings()
|
||||
return FcmConfig(
|
||||
enabled=bool(settings.sac_fcm_enabled),
|
||||
project_id=(settings.sac_fcm_project_id or "").strip(),
|
||||
service_account_path=(settings.sac_fcm_service_account_json or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
def _load_service_account(path: str) -> dict:
|
||||
file_path = Path(path)
|
||||
if not file_path.is_file():
|
||||
raise MobileNotConfiguredError(f"FCM service account file not found: {path}")
|
||||
with file_path.open(encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def _fcm_access_token(service_account_path: str) -> str:
|
||||
try:
|
||||
from google.oauth2 import service_account
|
||||
from google.auth.transport.requests import Request
|
||||
except ImportError as exc:
|
||||
msg = str(exc).lower()
|
||||
if "requests" in msg:
|
||||
raise MobileNotConfiguredError(
|
||||
"FCM: установите пакет requests (pip install requests)"
|
||||
) from exc
|
||||
raise MobileNotConfiguredError("FCM: установите google-auth") from exc
|
||||
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
service_account_path,
|
||||
scopes=[FCM_SCOPE],
|
||||
)
|
||||
creds.refresh(Request())
|
||||
if not creds.token:
|
||||
raise MobileSendError("Failed to obtain FCM access token")
|
||||
return creds.token
|
||||
|
||||
|
||||
def _active_device_tokens(db: Session | None) -> list[str]:
|
||||
if db is None:
|
||||
return []
|
||||
rows = db.scalars(
|
||||
select(MobileDevice).where(
|
||||
MobileDevice.revoked_at.is_(None),
|
||||
MobileDevice.fcm_token.is_not(None),
|
||||
)
|
||||
).all()
|
||||
tokens: list[str] = []
|
||||
for row in rows:
|
||||
token = (row.fcm_token or "").strip()
|
||||
if token:
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def _send_fcm_data(
|
||||
tokens: list[str],
|
||||
data: dict[str, str],
|
||||
*,
|
||||
title: str,
|
||||
body: str,
|
||||
require_success: bool = False,
|
||||
) -> None:
|
||||
if not tokens:
|
||||
if require_success:
|
||||
raise MobileSendError("Нет FCM-токенов для отправки")
|
||||
return
|
||||
cfg = get_effective_fcm_config()
|
||||
if not cfg.active:
|
||||
if require_success:
|
||||
raise MobileNotConfiguredError("FCM не настроен на сервере")
|
||||
return
|
||||
|
||||
try:
|
||||
access_token = _fcm_access_token(cfg.service_account_path)
|
||||
except (MobileNotConfiguredError, MobileSendError) as exc:
|
||||
logger.warning("FCM skipped: %s", exc)
|
||||
if require_success:
|
||||
raise
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("FCM token acquisition failed")
|
||||
if require_success:
|
||||
raise MobileSendError("Failed to obtain FCM access token", status_code=502)
|
||||
return
|
||||
|
||||
url = f"https://fcm.googleapis.com/v1/projects/{cfg.project_id}/messages:send"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
errors: list[str] = []
|
||||
|
||||
# Data-only: Android always calls SeacaMessagingService.onMessageReceived so the app
|
||||
# can honour per-device notification mode (sound / silent / off).
|
||||
message_data = {**data, "title": title, "body": body}
|
||||
|
||||
with httpx.Client(timeout=12.0) as client:
|
||||
for token in tokens:
|
||||
payload = {
|
||||
"message": {
|
||||
"token": token,
|
||||
"data": message_data,
|
||||
"android": {"priority": "high"},
|
||||
}
|
||||
}
|
||||
try:
|
||||
response = client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text[:200] if exc.response is not None else str(exc)
|
||||
logger.warning("FCM send failed for token prefix %s: %s", token[:8], detail)
|
||||
errors.append(detail or f"HTTP {exc.response.status_code if exc.response else '?'}")
|
||||
except Exception as exc:
|
||||
logger.exception("FCM send failed")
|
||||
errors.append(str(exc))
|
||||
|
||||
if require_success and errors:
|
||||
raise MobileSendError(errors[0], status_code=502)
|
||||
|
||||
|
||||
def _event_payload(event: Event) -> tuple[str, str, dict[str, str]]:
|
||||
title, body = format_event_mobile_push(event)
|
||||
data = {
|
||||
"kind": "event",
|
||||
"id": str(event.id),
|
||||
"severity": event.severity,
|
||||
"type": event.type,
|
||||
}
|
||||
return title, body, data
|
||||
|
||||
|
||||
def _problem_payload(problem: Problem) -> tuple[str, str, dict[str, str]]:
|
||||
title = f"[{problem.severity}] {problem.title}"
|
||||
body = (problem.summary or "")[:200]
|
||||
data = {
|
||||
"kind": "problem",
|
||||
"id": str(problem.id),
|
||||
"severity": problem.severity,
|
||||
"status": problem.status,
|
||||
}
|
||||
return title, body, data
|
||||
|
||||
|
||||
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
|
||||
policy = get_effective_notification_policy(db)
|
||||
if apply_policy_gate and not severity_meets_minimum(event.severity, policy.min_severity):
|
||||
return
|
||||
tokens = _active_device_tokens(db)
|
||||
if not tokens:
|
||||
return
|
||||
title, body, data = _event_payload(event)
|
||||
_send_fcm_data(tokens, data, title=title, body=body)
|
||||
|
||||
|
||||
def notify_problem(
|
||||
problem: Problem,
|
||||
event: Event | None = None,
|
||||
*,
|
||||
db: Session | None = None,
|
||||
apply_policy_gate: bool = True,
|
||||
) -> None:
|
||||
policy = get_effective_notification_policy(db)
|
||||
if apply_policy_gate and not severity_meets_minimum(problem.severity, policy.min_severity):
|
||||
return
|
||||
tokens = _active_device_tokens(db)
|
||||
if not tokens:
|
||||
return
|
||||
title, body, data = _problem_payload(problem)
|
||||
_send_fcm_data(tokens, data, title=title, body=body)
|
||||
|
||||
|
||||
def send_test_push(*, db: Session, device_id: int) -> None:
|
||||
cfg = get_effective_fcm_config()
|
||||
if not cfg.enabled:
|
||||
raise MobileNotConfiguredError("FCM отключён (SAC_FCM_ENABLED=false)")
|
||||
if not cfg.configured:
|
||||
raise MobileNotConfiguredError("Не задан SAC_FCM_PROJECT_ID или SAC_FCM_SERVICE_ACCOUNT_JSON")
|
||||
|
||||
device = db.get(MobileDevice, device_id)
|
||||
if device is None or device.revoked_at is not None:
|
||||
raise ValueError("device not found or revoked")
|
||||
token = (device.fcm_token or "").strip()
|
||||
if not token:
|
||||
raise ValueError("device has no FCM token")
|
||||
|
||||
_send_fcm_data(
|
||||
[token],
|
||||
{"kind": "test", "id": "0", "severity": "info"},
|
||||
title="SAC: тестовое push",
|
||||
body="Канал Seaca (FCM) работает.",
|
||||
require_success=True,
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.mobile_settings import MOBILE_SETTINGS_ROW_ID, MobileSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MobileSettingsConfig:
|
||||
devices_allowed: bool
|
||||
max_devices_per_user: int
|
||||
min_app_version: str | None
|
||||
source: str = "db"
|
||||
|
||||
|
||||
def get_effective_mobile_settings(db: Session) -> MobileSettingsConfig:
|
||||
row = db.get(MobileSettings, MOBILE_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
return MobileSettingsConfig(
|
||||
devices_allowed=False,
|
||||
max_devices_per_user=3,
|
||||
min_app_version=None,
|
||||
source="default",
|
||||
)
|
||||
return MobileSettingsConfig(
|
||||
devices_allowed=bool(row.devices_allowed),
|
||||
max_devices_per_user=max(1, int(row.max_devices_per_user or 3)),
|
||||
min_app_version=(row.min_app_version or "").strip() or None,
|
||||
source="db",
|
||||
)
|
||||
|
||||
|
||||
def upsert_mobile_settings(
|
||||
db: Session,
|
||||
*,
|
||||
devices_allowed: bool,
|
||||
max_devices_per_user: int,
|
||||
min_app_version: str | None,
|
||||
) -> MobileSettingsConfig:
|
||||
if max_devices_per_user < 1 or max_devices_per_user > 50:
|
||||
raise ValueError("max_devices_per_user must be between 1 and 50")
|
||||
cleaned_version = (min_app_version or "").strip() or None
|
||||
row = db.get(MobileSettings, MOBILE_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
row = MobileSettings(
|
||||
id=MOBILE_SETTINGS_ROW_ID,
|
||||
devices_allowed=devices_allowed,
|
||||
max_devices_per_user=max_devices_per_user,
|
||||
min_app_version=cleaned_version,
|
||||
)
|
||||
db.add(row)
|
||||
else:
|
||||
row.devices_allowed = devices_allowed
|
||||
row.max_devices_per_user = max_devices_per_user
|
||||
row.min_app_version = cleaned_version
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_mobile_settings(db)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Хеширование enrollment/refresh токенов и генерация."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.mobile_refresh_token import MobileRefreshToken
|
||||
|
||||
|
||||
def hash_mobile_secret(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_enrollment_code() -> tuple[str, str, str]:
|
||||
"""(plaintext, prefix, hash)"""
|
||||
raw = f"sacmob_{secrets.token_urlsafe(18)}"
|
||||
prefix = raw[:12]
|
||||
return raw, prefix, hash_mobile_secret(raw)
|
||||
|
||||
|
||||
def generate_refresh_token() -> tuple[str, str]:
|
||||
"""(plaintext, hash)"""
|
||||
raw = secrets.token_urlsafe(48)
|
||||
return raw, hash_mobile_secret(raw)
|
||||
|
||||
|
||||
def store_refresh_token(db: Session, *, device_id: int) -> str:
|
||||
settings = get_settings()
|
||||
days = max(1, int(settings.sac_mobile_refresh_expire_days))
|
||||
plaintext, token_hash = generate_refresh_token()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
row = MobileRefreshToken(
|
||||
device_id=device_id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(row)
|
||||
return plaintext
|
||||
|
||||
|
||||
def revoke_refresh_tokens_for_device(db: Session, device_id: int) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = db.scalars(
|
||||
select(MobileRefreshToken).where(
|
||||
MobileRefreshToken.device_id == device_id,
|
||||
MobileRefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
for row in rows:
|
||||
row.revoked_at = now
|
||||
|
||||
|
||||
def find_valid_refresh_token(db: Session, raw_token: str) -> MobileRefreshToken | None:
|
||||
token_hash = hash_mobile_secret(raw_token)
|
||||
now = datetime.now(timezone.utc)
|
||||
return db.scalar(
|
||||
select(MobileRefreshToken).where(
|
||||
MobileRefreshToken.token_hash == token_hash,
|
||||
MobileRefreshToken.revoked_at.is_(None),
|
||||
MobileRefreshToken.expires_at > now,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Cooldown / dedup for outbound notifications (F-NOT-03)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Event, Problem
|
||||
from app.models.notification_cooldown import NotificationCooldown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COOLDOWN_KIND_EVENT = "event"
|
||||
COOLDOWN_KIND_PROBLEM = "problem"
|
||||
|
||||
# Не душим служебные проверки ingest
|
||||
_EVENT_COOLDOWN_EXEMPT_TYPES = frozenset({"agent.test", "agent.lifecycle"})
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def build_event_cooldown_key(event: Event) -> str:
|
||||
if event.dedup_key and str(event.dedup_key).strip():
|
||||
return f"event:{event.dedup_key.strip()}"
|
||||
|
||||
details: dict[str, Any] = event.details if isinstance(event.details, dict) else {}
|
||||
host = event.host.hostname if event.host else "unknown"
|
||||
ip = str(details.get("source_ip") or details.get("ip_address") or details.get("ip") or "").strip()
|
||||
user = str(details.get("user") or details.get("username") or "").strip()
|
||||
return f"event:{event.type}|{host}|{ip}|{user}"
|
||||
|
||||
|
||||
def build_problem_cooldown_key(problem: Problem) -> str:
|
||||
fp = (problem.fingerprint or "").strip() or f"id:{problem.id}"
|
||||
return f"problem:{fp}"
|
||||
|
||||
|
||||
def _cooldown_seconds_for_kind(kind: str) -> int:
|
||||
settings = get_settings()
|
||||
if not settings.sac_notify_cooldown_enabled:
|
||||
return 0
|
||||
if kind == COOLDOWN_KIND_PROBLEM:
|
||||
return max(0, int(settings.sac_notify_problem_cooldown_sec))
|
||||
return max(0, int(settings.sac_notify_event_cooldown_sec))
|
||||
|
||||
|
||||
def allow_notify(db: Session | None, *, key: str, kind: str) -> bool:
|
||||
"""True = можно слать сейчас; при True обновляет last_notified_at."""
|
||||
if kind == COOLDOWN_KIND_EVENT:
|
||||
cooldown_sec = _cooldown_seconds_for_kind(COOLDOWN_KIND_EVENT)
|
||||
else:
|
||||
cooldown_sec = _cooldown_seconds_for_kind(COOLDOWN_KIND_PROBLEM)
|
||||
|
||||
if cooldown_sec <= 0:
|
||||
return True
|
||||
if db is None:
|
||||
return True
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
row = db.get(NotificationCooldown, key)
|
||||
if row is not None:
|
||||
last = _as_utc(row.last_notified_at)
|
||||
elapsed = (now - last).total_seconds()
|
||||
if elapsed >= 0 and elapsed < cooldown_sec:
|
||||
logger.info(
|
||||
"notify cooldown skip kind=%s key=%s elapsed=%.0fs need=%ss",
|
||||
kind,
|
||||
key[:120],
|
||||
elapsed,
|
||||
cooldown_sec,
|
||||
)
|
||||
return False
|
||||
|
||||
if row is None:
|
||||
db.add(NotificationCooldown(cooldown_key=key, kind=kind, last_notified_at=now))
|
||||
else:
|
||||
row.kind = kind
|
||||
row.last_notified_at = now
|
||||
db.flush()
|
||||
return True
|
||||
|
||||
|
||||
def should_notify_event(event: Event, db: Session | None) -> bool:
|
||||
if event.type in _EVENT_COOLDOWN_EXEMPT_TYPES:
|
||||
return True
|
||||
key = build_event_cooldown_key(event)
|
||||
return allow_notify(db, key=key, kind=COOLDOWN_KIND_EVENT)
|
||||
|
||||
|
||||
def should_notify_problem(problem: Problem, db: Session | None) -> bool:
|
||||
key = build_problem_cooldown_key(problem)
|
||||
return allow_notify(db, key=key, kind=COOLDOWN_KIND_PROBLEM)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Global notification policy: severity ≥ min → selected channels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.notification_channel import NotificationChannel
|
||||
from app.models.notification_policy import POLICY_ROW_ID, NotificationPolicy
|
||||
from app.services.notification_settings import (
|
||||
CHANNEL_EMAIL,
|
||||
CHANNEL_TELEGRAM,
|
||||
CHANNEL_WEBHOOK,
|
||||
VALID_SEVERITIES,
|
||||
)
|
||||
|
||||
CHANNEL_MOBILE = "mobile"
|
||||
DEFAULT_CHANNELS = frozenset({CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL, CHANNEL_MOBILE})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotificationPolicyConfig:
|
||||
min_severity: str
|
||||
use_telegram: bool
|
||||
use_webhook: bool
|
||||
use_email: bool
|
||||
use_mobile: bool
|
||||
source: str # env | db
|
||||
|
||||
def allows_channel(self, channel: str) -> bool:
|
||||
if channel == CHANNEL_TELEGRAM:
|
||||
return self.use_telegram
|
||||
if channel == CHANNEL_WEBHOOK:
|
||||
return self.use_webhook
|
||||
if channel == CHANNEL_EMAIL:
|
||||
return self.use_email
|
||||
if channel == CHANNEL_MOBILE:
|
||||
return self.use_mobile
|
||||
return False
|
||||
|
||||
|
||||
def _parse_channels_csv(value: str) -> tuple[bool, bool, bool, bool]:
|
||||
parts = {p.strip().lower() for p in value.split(",") if p.strip()}
|
||||
return (
|
||||
CHANNEL_TELEGRAM in parts or "tg" in parts,
|
||||
CHANNEL_WEBHOOK in parts,
|
||||
CHANNEL_EMAIL in parts or "mail" in parts or "smtp" in parts,
|
||||
CHANNEL_MOBILE in parts or "push" in parts or "fcm" in parts,
|
||||
)
|
||||
|
||||
|
||||
def _policy_from_env() -> NotificationPolicyConfig:
|
||||
settings = get_settings()
|
||||
use_tg, use_wh, use_em, use_mob = _parse_channels_csv(settings.notify_channels)
|
||||
min_sev = settings.notify_min_severity.strip() or "warning"
|
||||
if min_sev not in VALID_SEVERITIES:
|
||||
min_sev = "warning"
|
||||
return NotificationPolicyConfig(
|
||||
min_severity=min_sev,
|
||||
use_telegram=use_tg,
|
||||
use_webhook=use_wh,
|
||||
use_email=use_em,
|
||||
use_mobile=use_mob,
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def get_effective_notification_policy(db: Session | None = None) -> NotificationPolicyConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_notification_policy(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.get(NotificationPolicy, POLICY_ROW_ID)
|
||||
env_cfg = _policy_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
min_sev = (row.min_severity or "").strip() or env_cfg.min_severity
|
||||
if min_sev not in VALID_SEVERITIES:
|
||||
min_sev = env_cfg.min_severity
|
||||
|
||||
return NotificationPolicyConfig(
|
||||
min_severity=min_sev,
|
||||
use_telegram=bool(row.use_telegram),
|
||||
use_webhook=bool(row.use_webhook),
|
||||
use_email=bool(row.use_email),
|
||||
use_mobile=bool(getattr(row, "use_mobile", False)),
|
||||
source="db",
|
||||
)
|
||||
|
||||
|
||||
def _sync_channel_min_severity(db: Session, min_severity: str) -> None:
|
||||
for channel in (CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL):
|
||||
row = db.get(NotificationChannel, channel)
|
||||
if row is not None:
|
||||
row.min_severity = min_severity
|
||||
|
||||
|
||||
def upsert_notification_policy(
|
||||
db: Session,
|
||||
*,
|
||||
min_severity: str,
|
||||
use_telegram: bool,
|
||||
use_webhook: bool,
|
||||
use_email: bool,
|
||||
use_mobile: bool = False,
|
||||
) -> NotificationPolicyConfig:
|
||||
if min_severity not in VALID_SEVERITIES:
|
||||
raise ValueError(f"invalid min_severity: {min_severity}")
|
||||
|
||||
row = db.get(NotificationPolicy, POLICY_ROW_ID)
|
||||
if row is None:
|
||||
row = NotificationPolicy(
|
||||
id=POLICY_ROW_ID,
|
||||
min_severity=min_severity,
|
||||
use_telegram=use_telegram,
|
||||
use_webhook=use_webhook,
|
||||
use_email=use_email,
|
||||
use_mobile=use_mobile,
|
||||
)
|
||||
db.add(row)
|
||||
else:
|
||||
row.min_severity = min_severity
|
||||
row.use_telegram = use_telegram
|
||||
row.use_webhook = use_webhook
|
||||
row.use_email = use_email
|
||||
row.use_mobile = use_mobile
|
||||
|
||||
_sync_channel_min_severity(db, min_severity)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_notification_policy(db)
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Effective notification channel config (DB overrides env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.notification_channel import NotificationChannel
|
||||
|
||||
VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"})
|
||||
CHANNEL_TELEGRAM = "telegram"
|
||||
CHANNEL_WEBHOOK = "webhook"
|
||||
CHANNEL_EMAIL = "email"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TelegramConfig:
|
||||
enabled: bool
|
||||
bot_token: str
|
||||
chat_id: str
|
||||
min_severity: str
|
||||
source: str # env | db
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.bot_token.strip() and self.chat_id.strip())
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.enabled and self.configured
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebhookConfig:
|
||||
enabled: bool
|
||||
url: str
|
||||
secret_header: str
|
||||
secret: str
|
||||
min_severity: str
|
||||
source: str # env | db
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.url.strip())
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.enabled and self.configured
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailConfig:
|
||||
enabled: bool
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_user: str
|
||||
smtp_password: str
|
||||
mail_from: str
|
||||
mail_to: str
|
||||
smtp_starttls: bool
|
||||
smtp_ssl: bool
|
||||
min_severity: str
|
||||
source: str # env | db
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.smtp_host.strip() and self.mail_from.strip() and self.mail_to.strip())
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.enabled and self.configured
|
||||
|
||||
|
||||
def _telegram_from_env() -> TelegramConfig:
|
||||
settings = get_settings()
|
||||
return TelegramConfig(
|
||||
enabled=bool(settings.telegram_enabled),
|
||||
bot_token=settings.telegram_bot_token.strip(),
|
||||
chat_id=settings.telegram_chat_id.strip(),
|
||||
min_severity=settings.telegram_min_severity.strip() or "high",
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def get_effective_telegram_config(db: Session | None = None) -> TelegramConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_telegram_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_TELEGRAM))
|
||||
env_cfg = _telegram_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
token = (row.bot_token or "").strip() or env_cfg.bot_token
|
||||
chat_id = (row.chat_id or "").strip() or env_cfg.chat_id
|
||||
min_sev = (row.min_severity or "").strip() or env_cfg.min_severity
|
||||
if min_sev not in VALID_SEVERITIES:
|
||||
min_sev = env_cfg.min_severity
|
||||
|
||||
return TelegramConfig(
|
||||
enabled=bool(row.enabled),
|
||||
bot_token=token,
|
||||
chat_id=chat_id,
|
||||
min_severity=min_sev,
|
||||
source="db",
|
||||
)
|
||||
|
||||
|
||||
def upsert_telegram_channel(
|
||||
db: Session,
|
||||
*,
|
||||
enabled: bool | None = None,
|
||||
bot_token: str | None = None,
|
||||
chat_id: str | None = None,
|
||||
min_severity: str | None = None,
|
||||
) -> TelegramConfig:
|
||||
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_TELEGRAM))
|
||||
env_cfg = _telegram_from_env()
|
||||
|
||||
if row is None:
|
||||
row = NotificationChannel(
|
||||
channel=CHANNEL_TELEGRAM,
|
||||
enabled=env_cfg.enabled,
|
||||
bot_token=env_cfg.bot_token or None,
|
||||
chat_id=env_cfg.chat_id or None,
|
||||
min_severity=env_cfg.min_severity,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
if enabled is not None:
|
||||
row.enabled = enabled
|
||||
if min_severity is not None:
|
||||
if min_severity not in VALID_SEVERITIES:
|
||||
raise ValueError(f"invalid min_severity: {min_severity}")
|
||||
row.min_severity = min_severity
|
||||
if bot_token is not None and bot_token.strip():
|
||||
row.bot_token = bot_token.strip()
|
||||
if chat_id is not None and chat_id.strip():
|
||||
row.chat_id = chat_id.strip()
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_telegram_config(db)
|
||||
|
||||
|
||||
def _webhook_from_env() -> WebhookConfig:
|
||||
settings = get_settings()
|
||||
return WebhookConfig(
|
||||
enabled=bool(settings.webhook_enabled),
|
||||
url=settings.webhook_url.strip(),
|
||||
secret_header=settings.webhook_secret_header.strip(),
|
||||
secret=settings.webhook_secret.strip(),
|
||||
min_severity=settings.webhook_min_severity.strip() or "high",
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def get_effective_webhook_config(db: Session | None = None) -> WebhookConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_webhook_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_WEBHOOK))
|
||||
env_cfg = _webhook_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
url = (row.webhook_url or "").strip() or env_cfg.url
|
||||
secret_header = (row.webhook_secret_header or "").strip() or env_cfg.secret_header
|
||||
secret = (row.webhook_secret or "").strip() or env_cfg.secret
|
||||
min_sev = (row.min_severity or "").strip() or env_cfg.min_severity
|
||||
if min_sev not in VALID_SEVERITIES:
|
||||
min_sev = env_cfg.min_severity
|
||||
|
||||
return WebhookConfig(
|
||||
enabled=bool(row.enabled),
|
||||
url=url,
|
||||
secret_header=secret_header,
|
||||
secret=secret,
|
||||
min_severity=min_sev,
|
||||
source="db",
|
||||
)
|
||||
|
||||
|
||||
def upsert_webhook_channel(
|
||||
db: Session,
|
||||
*,
|
||||
enabled: bool | None = None,
|
||||
url: str | None = None,
|
||||
secret_header: str | None = None,
|
||||
secret: str | None = None,
|
||||
min_severity: str | None = None,
|
||||
) -> WebhookConfig:
|
||||
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_WEBHOOK))
|
||||
env_cfg = _webhook_from_env()
|
||||
|
||||
if row is None:
|
||||
row = NotificationChannel(
|
||||
channel=CHANNEL_WEBHOOK,
|
||||
enabled=env_cfg.enabled,
|
||||
min_severity=env_cfg.min_severity,
|
||||
webhook_url=env_cfg.url or None,
|
||||
webhook_secret_header=env_cfg.secret_header or None,
|
||||
webhook_secret=env_cfg.secret or None,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
if enabled is not None:
|
||||
row.enabled = enabled
|
||||
if min_severity is not None:
|
||||
if min_severity not in VALID_SEVERITIES:
|
||||
raise ValueError(f"invalid min_severity: {min_severity}")
|
||||
row.min_severity = min_severity
|
||||
if url is not None and url.strip():
|
||||
row.webhook_url = url.strip()
|
||||
if secret_header is not None:
|
||||
row.webhook_secret_header = secret_header.strip() or None
|
||||
if secret is not None and secret.strip():
|
||||
row.webhook_secret = secret.strip()
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_webhook_config(db)
|
||||
|
||||
|
||||
def _email_from_env() -> EmailConfig:
|
||||
settings = get_settings()
|
||||
return EmailConfig(
|
||||
enabled=bool(settings.smtp_enabled),
|
||||
smtp_host=settings.smtp_host.strip(),
|
||||
smtp_port=int(settings.smtp_port or 587),
|
||||
smtp_user=settings.smtp_user.strip(),
|
||||
smtp_password=settings.smtp_password,
|
||||
mail_from=settings.smtp_from.strip(),
|
||||
mail_to=settings.smtp_to.strip(),
|
||||
smtp_starttls=bool(settings.smtp_starttls),
|
||||
smtp_ssl=bool(settings.smtp_ssl),
|
||||
min_severity=settings.smtp_min_severity.strip() or "high",
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def get_effective_email_config(db: Session | None = None) -> EmailConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_email_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_EMAIL))
|
||||
env_cfg = _email_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
host = (row.smtp_host or "").strip() or env_cfg.smtp_host
|
||||
port = row.smtp_port if row.smtp_port is not None else env_cfg.smtp_port
|
||||
user = (row.smtp_user or "").strip() or env_cfg.smtp_user
|
||||
password = (row.smtp_password or "") or env_cfg.smtp_password
|
||||
mail_from = (row.mail_from or "").strip() or env_cfg.mail_from
|
||||
mail_to = (row.mail_to or "").strip() or env_cfg.mail_to
|
||||
min_sev = (row.min_severity or "").strip() or env_cfg.min_severity
|
||||
if min_sev not in VALID_SEVERITIES:
|
||||
min_sev = env_cfg.min_severity
|
||||
|
||||
return EmailConfig(
|
||||
enabled=bool(row.enabled),
|
||||
smtp_host=host,
|
||||
smtp_port=int(port or 587),
|
||||
smtp_user=user,
|
||||
smtp_password=password,
|
||||
mail_from=mail_from,
|
||||
mail_to=mail_to,
|
||||
smtp_starttls=bool(row.smtp_starttls),
|
||||
smtp_ssl=bool(row.smtp_ssl),
|
||||
min_severity=min_sev,
|
||||
source="db",
|
||||
)
|
||||
|
||||
|
||||
def upsert_email_channel(
|
||||
db: Session,
|
||||
*,
|
||||
enabled: bool | None = None,
|
||||
smtp_host: str | None = None,
|
||||
smtp_port: int | None = None,
|
||||
smtp_user: str | None = None,
|
||||
smtp_password: str | None = None,
|
||||
mail_from: str | None = None,
|
||||
mail_to: str | None = None,
|
||||
smtp_starttls: bool | None = None,
|
||||
smtp_ssl: bool | None = None,
|
||||
min_severity: str | None = None,
|
||||
) -> EmailConfig:
|
||||
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_EMAIL))
|
||||
env_cfg = _email_from_env()
|
||||
|
||||
if row is None:
|
||||
row = NotificationChannel(
|
||||
channel=CHANNEL_EMAIL,
|
||||
enabled=env_cfg.enabled,
|
||||
min_severity=env_cfg.min_severity,
|
||||
smtp_host=env_cfg.smtp_host or None,
|
||||
smtp_port=env_cfg.smtp_port,
|
||||
smtp_user=env_cfg.smtp_user or None,
|
||||
smtp_password=env_cfg.smtp_password or None,
|
||||
mail_from=env_cfg.mail_from or None,
|
||||
mail_to=env_cfg.mail_to or None,
|
||||
smtp_starttls=env_cfg.smtp_starttls,
|
||||
smtp_ssl=env_cfg.smtp_ssl,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
if enabled is not None:
|
||||
row.enabled = enabled
|
||||
if min_severity is not None:
|
||||
if min_severity not in VALID_SEVERITIES:
|
||||
raise ValueError(f"invalid min_severity: {min_severity}")
|
||||
row.min_severity = min_severity
|
||||
if smtp_host is not None and smtp_host.strip():
|
||||
row.smtp_host = smtp_host.strip()
|
||||
if smtp_port is not None:
|
||||
row.smtp_port = smtp_port
|
||||
if smtp_user is not None:
|
||||
row.smtp_user = smtp_user.strip() or None
|
||||
if smtp_password is not None and smtp_password.strip():
|
||||
row.smtp_password = smtp_password
|
||||
if mail_from is not None and mail_from.strip():
|
||||
row.mail_from = mail_from.strip()
|
||||
if mail_to is not None and mail_to.strip():
|
||||
row.mail_to = mail_to.strip()
|
||||
if smtp_starttls is not None:
|
||||
row.smtp_starttls = smtp_starttls
|
||||
if smtp_ssl is not None:
|
||||
row.smtp_ssl = smtp_ssl
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_email_config(db)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Severity ordering for notification policy gates."""
|
||||
|
||||
SEVERITY_ORDER = {"info": 10, "warning": 20, "high": 30, "critical": 40}
|
||||
|
||||
|
||||
def severity_value(value: str) -> int:
|
||||
return SEVERITY_ORDER.get(value, 0)
|
||||
|
||||
|
||||
def severity_meets_minimum(severity: str, minimum: str) -> bool:
|
||||
return severity_value(severity) >= severity_value(minimum)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Dispatch ingest notifications per global policy (severity → channels)."""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host, Problem
|
||||
from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify
|
||||
from app.services.notification_cooldown import should_notify_event, should_notify_problem
|
||||
from app.services.notification_policy import get_effective_notification_policy
|
||||
from app.services.event_type_visibility import event_type_notifications_enabled
|
||||
from app.services.host_health import HEARTBEAT_TYPE
|
||||
from app.services.notification_severity import severity_meets_minimum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LIFECYCLE_EVENT_TYPE = "agent.lifecycle"
|
||||
PRIVILEGE_SUDO_TYPE = "privilege.sudo.command"
|
||||
SUDO_MAINTENANCE_MARKERS = (
|
||||
"update_ssh_monitor.sh",
|
||||
"update_via_sac",
|
||||
"update_script.log",
|
||||
"/opt/scripts/update",
|
||||
"agent-update-in-progress",
|
||||
)
|
||||
DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
||||
AUTH_LOGIN_SUCCESS_TYPES = frozenset({"rdp.login.success", "ssh.login.success"})
|
||||
RDG_CONNECTION_TYPES = frozenset({
|
||||
"rdg.connection.success",
|
||||
"rdg.connection.disconnected",
|
||||
"rdg.connection.failed",
|
||||
})
|
||||
|
||||
|
||||
def _event_telegram_via_agent(event: Event) -> bool:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
via = str(details.get("telegram_via") or "").strip().lower()
|
||||
return via == "agent"
|
||||
|
||||
|
||||
def _skip_notifications_for_hidden_event(event: Event, db: Session | None) -> bool:
|
||||
if event_type_notifications_enabled(event.type, db):
|
||||
return False
|
||||
logger.info(
|
||||
"notify skipped hidden event type=%s event_id=%s",
|
||||
event.type,
|
||||
event.event_id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None:
|
||||
if policy.use_telegram:
|
||||
telegram_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||
if policy.use_webhook:
|
||||
webhook_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||
if policy.use_email:
|
||||
email_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||
if policy.use_mobile:
|
||||
mobile_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||
|
||||
|
||||
def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Session | None, policy) -> None:
|
||||
if policy.use_telegram:
|
||||
telegram_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
|
||||
if policy.use_webhook:
|
||||
webhook_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
|
||||
if policy.use_email:
|
||||
email_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
|
||||
if policy.use_mobile:
|
||||
mobile_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
|
||||
|
||||
|
||||
def notify_event(event: Event, *, db: Session | None = None) -> None:
|
||||
# Heartbeat — только для UI/статуса хоста, не для Telegram/email/push.
|
||||
if event.type == HEARTBEAT_TYPE:
|
||||
return
|
||||
if _should_suppress_sudo_notify(event, db=db):
|
||||
return
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not severity_meets_minimum(event.severity, policy.min_severity):
|
||||
return
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
_dispatch_event_channels(event, db=db, policy=policy)
|
||||
|
||||
|
||||
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not severity_meets_minimum(problem.severity, policy.min_severity):
|
||||
return
|
||||
if not should_notify_problem(problem, db):
|
||||
return
|
||||
_dispatch_problem_channels(problem, event, db=db, policy=policy)
|
||||
|
||||
|
||||
def _dispatch_lifecycle_channels(event: Event, *, db: Session | None, policy) -> None:
|
||||
skip_telegram = _event_telegram_via_agent(event)
|
||||
if policy.use_telegram and not skip_telegram:
|
||||
telegram_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||
if policy.use_webhook:
|
||||
webhook_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||
if policy.use_email:
|
||||
email_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||
if policy.use_mobile:
|
||||
mobile_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||
|
||||
|
||||
def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
|
||||
"""Оповещение по суточному отчёту (severity=info, вне порога policy).
|
||||
|
||||
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
|
||||
"""
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
_dispatch_lifecycle_channels(event, db=db, policy=policy)
|
||||
|
||||
|
||||
def _host_sac_update_running(event: Event, db: Session | None) -> bool:
|
||||
"""Пока SAC выполняет agent-update на хосте — не слать шумные TG."""
|
||||
if db is None or not event.host_id:
|
||||
return False
|
||||
host = db.get(Host, event.host_id)
|
||||
if host is None:
|
||||
return False
|
||||
if (host.agent_update_state or "").strip().lower() == "running":
|
||||
logger.info(
|
||||
"notify skipped (host update running) type=%s host_id=%s event_id=%s",
|
||||
event.type,
|
||||
host.id,
|
||||
event.event_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _sudo_event_is_agent_maintenance(event: Event) -> bool:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
cmd = str(details.get("command") or "").strip()
|
||||
if not cmd:
|
||||
cmd = str(event.summary or "").strip()
|
||||
text = cmd.casefold()
|
||||
return any(marker in text for marker in SUDO_MAINTENANCE_MARKERS)
|
||||
|
||||
|
||||
def _should_suppress_sudo_notify(event: Event, *, db: Session | None) -> bool:
|
||||
if event.type != PRIVILEGE_SUDO_TYPE:
|
||||
return False
|
||||
if _host_sac_update_running(event, db):
|
||||
return True
|
||||
if _sudo_event_is_agent_maintenance(event):
|
||||
logger.info(
|
||||
"notify sudo skipped maintenance command event_id=%s host_id=%s",
|
||||
event.event_id,
|
||||
event.host_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _lifecycle_suppress_notifications(event: Event, *, db: Session | None = None) -> bool:
|
||||
"""Не слать TG при штатном SAC/cron update (lifecycle с trigger deploy_recycle)."""
|
||||
if _host_sac_update_running(event, db):
|
||||
return True
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
trigger = str(details.get("trigger") or "").strip().lower()
|
||||
if trigger in ("deploy_recycle", "sac_update", "agent_update"):
|
||||
logger.info(
|
||||
"notify lifecycle skipped trigger=%s event_id=%s",
|
||||
trigger,
|
||||
event.event_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def notify_lifecycle(event: Event, *, db: Session | None = None) -> None:
|
||||
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
|
||||
if _lifecycle_suppress_notifications(event, db=db):
|
||||
return
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
_dispatch_lifecycle_channels(event, db=db, policy=policy)
|
||||
|
||||
|
||||
def notify_auth_login(event: Event, *, db: Session | None = None) -> None:
|
||||
"""Успешный удалённый вход RDP/SSH — всегда в Telegram SAC (info вне min_severity).
|
||||
|
||||
При UseSAC=dual агент шлёт TG сам (telegram_via=agent) — SAC не дублирует.
|
||||
"""
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
_dispatch_lifecycle_channels(event, db=db, policy=policy)
|
||||
|
||||
|
||||
def notify_rdg_connection(event: Event, *, db: Session | None = None) -> None:
|
||||
"""RD Gateway 302/303 — ingest всегда; Telegram SAC вне min_severity (как auth login)."""
|
||||
if _skip_notifications_for_hidden_event(event, db):
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not should_notify_event(event, db):
|
||||
return
|
||||
_dispatch_lifecycle_channels(event, db=db, policy=policy)
|
||||
|
||||
|
||||
def schedule_notify_daily_report(event_db_id: int) -> None:
|
||||
"""Отложенное оповещение по суточному отчёту (после commit ingest, вне горячего POST)."""
|
||||
_schedule_deferred_event_notify(event_db_id, notify_daily_report, label="daily report")
|
||||
|
||||
|
||||
def schedule_notify_lifecycle(event_db_id: int) -> None:
|
||||
"""Отложенное lifecycle-оповещение (после commit ingest)."""
|
||||
_schedule_deferred_event_notify(event_db_id, notify_lifecycle, label="lifecycle")
|
||||
|
||||
|
||||
def schedule_notify_auth_login(event_db_id: int) -> None:
|
||||
"""Отложенное оповещение об успешном RDP/SSH входе (после commit ingest)."""
|
||||
_schedule_deferred_event_notify(event_db_id, notify_auth_login, label="auth login")
|
||||
|
||||
|
||||
def _schedule_deferred_event_notify(event_db_id: int, handler, *, label: str) -> None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
event = db.get(Event, event_db_id)
|
||||
if event is None:
|
||||
logger.warning("deferred %s notify: event id=%s not found", label, event_db_id)
|
||||
return
|
||||
handler(event, db=db)
|
||||
except Exception:
|
||||
logger.exception("deferred %s notify failed event_db_id=%s", label, event_db_id)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Problem rule evaluators (v1): brute-force, privilege spike, host silence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Event, Host
|
||||
from app.services.host_health import HEARTBEAT_TYPE, agent_status
|
||||
|
||||
BRUTE_FAILED_TYPES = frozenset({"ssh.login.failed", "rdp.login.failed"})
|
||||
PRIVILEGE_SUDO_TYPE = "privilege.sudo.command"
|
||||
|
||||
RULE_BRUTE_FORCE = "rule:brute_force_burst"
|
||||
RULE_PRIVILEGE_SPIKE = "rule:privilege_spike"
|
||||
RULE_HOST_SILENCE = "rule:host_silence"
|
||||
RULE_RDG_SESSION_FLAP = "rule:rdg_session_flap"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuleMatch:
|
||||
rule_id: str
|
||||
correlation_type: str
|
||||
title: str
|
||||
summary: str
|
||||
severity: str
|
||||
fingerprint_suffix: str = ""
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _event_source_ip(event: Event) -> str:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
ip = details.get("source_ip") or details.get("ip")
|
||||
return str(ip) if ip else "unknown"
|
||||
|
||||
|
||||
def count_events_in_window(
|
||||
db: Session,
|
||||
*,
|
||||
host_id: int,
|
||||
event_type: str,
|
||||
since: datetime,
|
||||
source_ip: str | None = None,
|
||||
) -> int:
|
||||
rows = db.scalars(
|
||||
select(Event).where(
|
||||
Event.host_id == host_id,
|
||||
Event.type == event_type,
|
||||
Event.occurred_at >= since,
|
||||
)
|
||||
).all()
|
||||
if source_ip is None:
|
||||
return len(rows)
|
||||
return sum(1 for e in rows if _event_source_ip(e) == source_ip)
|
||||
|
||||
|
||||
def last_heartbeat_at(db: Session, host_id: int) -> datetime | None:
|
||||
return db.scalar(
|
||||
select(func.max(Event.received_at)).where(
|
||||
Event.host_id == host_id,
|
||||
Event.type == HEARTBEAT_TYPE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def evaluate_brute_force_burst(db: Session, event: Event) -> RuleMatch | None:
|
||||
if event.type not in BRUTE_FAILED_TYPES:
|
||||
return None
|
||||
settings = get_settings()
|
||||
source_ip = _event_source_ip(event)
|
||||
since = _now() - timedelta(minutes=settings.sac_brute_force_window_minutes)
|
||||
count = count_events_in_window(
|
||||
db,
|
||||
host_id=event.host_id,
|
||||
event_type=event.type,
|
||||
since=since,
|
||||
source_ip=source_ip,
|
||||
)
|
||||
if count < settings.sac_brute_force_threshold:
|
||||
return None
|
||||
return RuleMatch(
|
||||
rule_id=RULE_BRUTE_FORCE,
|
||||
correlation_type=event.type,
|
||||
fingerprint_suffix=f"ip{source_ip}",
|
||||
title=f"Brute-force burst: {count} failed logins",
|
||||
summary=(
|
||||
f"{count} событий {event.type} за {settings.sac_brute_force_window_minutes} мин "
|
||||
f"(IP {source_ip}, порог {settings.sac_brute_force_threshold})"
|
||||
),
|
||||
severity="high",
|
||||
)
|
||||
|
||||
|
||||
def evaluate_privilege_spike(db: Session, event: Event) -> RuleMatch | None:
|
||||
if event.type != PRIVILEGE_SUDO_TYPE:
|
||||
return None
|
||||
settings = get_settings()
|
||||
since = _now() - timedelta(minutes=settings.sac_privilege_spike_window_minutes)
|
||||
count = count_events_in_window(
|
||||
db,
|
||||
host_id=event.host_id,
|
||||
event_type=event.type,
|
||||
since=since,
|
||||
)
|
||||
if count < settings.sac_privilege_spike_threshold:
|
||||
return None
|
||||
return RuleMatch(
|
||||
rule_id=RULE_PRIVILEGE_SPIKE,
|
||||
correlation_type=event.type,
|
||||
title=f"Privilege spike: {count} sudo commands",
|
||||
summary=(
|
||||
f"{count} событий {event.type} за {settings.sac_privilege_spike_window_minutes} мин "
|
||||
f"(порог {settings.sac_privilege_spike_threshold})"
|
||||
),
|
||||
severity="warning" if count < settings.sac_privilege_spike_threshold * 2 else "high",
|
||||
)
|
||||
|
||||
|
||||
def host_silence_match_for_host(
|
||||
db: Session,
|
||||
host_id: int,
|
||||
*,
|
||||
hostname: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> RuleMatch | None:
|
||||
"""Сформировать RuleMatch host_silence, если последний heartbeat устарел."""
|
||||
settings = get_settings()
|
||||
ref = now or _now()
|
||||
hb = last_heartbeat_at(db, host_id)
|
||||
if hb is None:
|
||||
return None
|
||||
if agent_status(hb, stale_minutes=settings.sac_heartbeat_stale_minutes, now=ref) != "stale":
|
||||
return None
|
||||
if hostname is None:
|
||||
host = db.get(Host, host_id)
|
||||
hostname = host.hostname if host else f"host#{host_id}"
|
||||
hb_utc = hb.replace(tzinfo=timezone.utc) if hb.tzinfo is None else hb.astimezone(timezone.utc)
|
||||
age_min = int((ref - hb_utc).total_seconds() // 60)
|
||||
return RuleMatch(
|
||||
rule_id=RULE_HOST_SILENCE,
|
||||
correlation_type="host.silence",
|
||||
title=f"Host silence: {hostname}",
|
||||
summary=(
|
||||
f"Нет свежего agent.heartbeat более {settings.sac_heartbeat_stale_minutes} мин "
|
||||
f"(последний ~{age_min} мин назад)"
|
||||
),
|
||||
severity="high",
|
||||
)
|
||||
|
||||
|
||||
def evaluate_host_silence(db: Session, event: Event) -> RuleMatch | None:
|
||||
if event.type == HEARTBEAT_TYPE:
|
||||
return None
|
||||
host = db.get(Host, event.host_id)
|
||||
hostname = host.hostname if host else None
|
||||
return host_silence_match_for_host(db, event.host_id, hostname=hostname)
|
||||
|
||||
|
||||
def evaluate_immediate_high(event: Event) -> RuleMatch | None:
|
||||
"""Одиночные high/critical и известные типы (ban, mass bruteforce)."""
|
||||
problem_types = frozenset({"ssh.ip.banned", "ssh.bruteforce.mass"})
|
||||
if event.severity not in frozenset({"high", "critical"}) and event.type not in problem_types:
|
||||
return None
|
||||
rule_id = "high_severity" if event.severity in frozenset({"high", "critical"}) else f"type:{event.type}"
|
||||
return RuleMatch(
|
||||
rule_id=rule_id,
|
||||
correlation_type=event.type,
|
||||
title=event.title,
|
||||
summary=event.summary,
|
||||
severity=event.severity,
|
||||
)
|
||||
|
||||
|
||||
def resolve_host_silence_problems(db: Session, host_id: int) -> int:
|
||||
"""Закрыть open/ack host silence при свежем heartbeat."""
|
||||
from app.models import Problem
|
||||
|
||||
problems = db.scalars(
|
||||
select(Problem).where(
|
||||
Problem.host_id == host_id,
|
||||
Problem.rule_id == RULE_HOST_SILENCE,
|
||||
Problem.status.in_(("open", "acknowledged")),
|
||||
)
|
||||
).all()
|
||||
for problem in problems:
|
||||
problem.status = "resolved"
|
||||
problem.resolved_by = "auto"
|
||||
if problems:
|
||||
db.flush()
|
||||
return len(problems)
|
||||
|
||||
|
||||
def build_fingerprint(host_id: int, correlation_type: str, rule_id: str, suffix: str = "") -> str:
|
||||
base = f"h{host_id}"
|
||||
if suffix:
|
||||
base = f"{base}:{suffix}"
|
||||
return f"{base}:t{correlation_type}:r{rule_id}"
|
||||
|
||||
|
||||
def pick_rule_match(db: Session, event: Event) -> RuleMatch | None:
|
||||
"""Первое сработавшее правило (приоритет: rdg flap → burst → spike → silence → immediate)."""
|
||||
from app.services.rdg_session_flap import evaluate_rdg_session_flap
|
||||
|
||||
for evaluator in (
|
||||
evaluate_rdg_session_flap,
|
||||
evaluate_brute_force_burst,
|
||||
evaluate_privilege_spike,
|
||||
evaluate_host_silence,
|
||||
):
|
||||
match = evaluator(db, event)
|
||||
if match is not None:
|
||||
return match
|
||||
return evaluate_immediate_high(event)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Auto-create Problems from ingested events (rules + correlation)."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Event, Problem, ProblemEvent
|
||||
from app.services.host_health import HEARTBEAT_TYPE
|
||||
from app.services.problem_rules import (
|
||||
RuleMatch,
|
||||
RULE_HOST_SILENCE,
|
||||
RULE_RDG_SESSION_FLAP,
|
||||
build_fingerprint,
|
||||
pick_rule_match,
|
||||
resolve_host_silence_problems,
|
||||
)
|
||||
|
||||
|
||||
def _correlation_cutoff(now: datetime) -> datetime:
|
||||
minutes = get_settings().sac_problem_correlation_window_minutes
|
||||
return now - timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def _append_event(db: Session, problem: Problem, event: Event) -> None:
|
||||
linked = db.scalar(
|
||||
select(ProblemEvent).where(
|
||||
ProblemEvent.problem_id == problem.id,
|
||||
ProblemEvent.event_id == event.id,
|
||||
)
|
||||
)
|
||||
if linked is not None:
|
||||
return
|
||||
db.add(ProblemEvent(problem_id=problem.id, event_id=event.id))
|
||||
problem.event_count = (problem.event_count or 0) + 1
|
||||
problem.last_seen_at = event.occurred_at
|
||||
problem.updated_at = datetime.now(timezone.utc)
|
||||
if event.severity in {"high", "critical"} and problem.severity not in {"high", "critical"}:
|
||||
problem.severity = event.severity
|
||||
db.flush()
|
||||
|
||||
|
||||
def open_or_append_problem(
|
||||
db: Session, event: Event, match: RuleMatch
|
||||
) -> tuple[Problem | None, bool]:
|
||||
if match.rule_id == RULE_HOST_SILENCE:
|
||||
from app.services.host_silence_scan import open_or_refresh_host_silence_problem
|
||||
|
||||
ref = event.occurred_at
|
||||
if ref.tzinfo is None:
|
||||
ref = ref.replace(tzinfo=timezone.utc)
|
||||
problem, created = open_or_refresh_host_silence_problem(
|
||||
db, event.host_id, match, now=ref, hostname=event.host.hostname if event.host else None
|
||||
)
|
||||
if problem is None:
|
||||
return None, False
|
||||
_append_event(db, problem, event)
|
||||
return problem, created
|
||||
|
||||
fingerprint = build_fingerprint(
|
||||
event.host_id,
|
||||
match.correlation_type,
|
||||
match.rule_id,
|
||||
match.fingerprint_suffix,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
if match.rule_id == RULE_RDG_SESSION_FLAP:
|
||||
cutoff = now - timedelta(seconds=get_settings().sac_rdg_flap_dedup_sec)
|
||||
else:
|
||||
cutoff = _correlation_cutoff(now)
|
||||
|
||||
open_problem = db.scalar(
|
||||
select(Problem)
|
||||
.where(
|
||||
Problem.status == "open",
|
||||
Problem.fingerprint == fingerprint,
|
||||
Problem.host_id == event.host_id,
|
||||
Problem.last_seen_at >= cutoff,
|
||||
)
|
||||
.order_by(Problem.last_seen_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if open_problem:
|
||||
_append_event(db, open_problem, event)
|
||||
if match.severity in {"high", "critical"} and open_problem.severity not in {"high", "critical"}:
|
||||
open_problem.severity = match.severity
|
||||
open_problem.summary = match.summary
|
||||
return open_problem, False
|
||||
|
||||
problem = Problem(
|
||||
host_id=event.host_id,
|
||||
title=match.title,
|
||||
summary=match.summary,
|
||||
severity=match.severity,
|
||||
status="open",
|
||||
rule_id=match.rule_id,
|
||||
fingerprint=fingerprint,
|
||||
event_count=1,
|
||||
last_seen_at=event.occurred_at,
|
||||
)
|
||||
db.add(problem)
|
||||
db.flush()
|
||||
db.add(ProblemEvent(problem_id=problem.id, event_id=event.id))
|
||||
db.flush()
|
||||
return problem, True
|
||||
|
||||
|
||||
def maybe_create_problem(db: Session, event: Event) -> tuple[Problem | None, bool]:
|
||||
if event.type == HEARTBEAT_TYPE:
|
||||
resolve_host_silence_problems(db, event.host_id)
|
||||
return None, False
|
||||
|
||||
match = pick_rule_match(db, event)
|
||||
if match is None:
|
||||
return None, False
|
||||
|
||||
problem, created = open_or_append_problem(db, event, match)
|
||||
return problem, created
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Resolve RDG client workstation (session host) from event internal_ip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.rdg_session_flap import event_internal_ip
|
||||
from app.services.winrm_connect import is_windows_host
|
||||
|
||||
|
||||
class ClientWorkstationNotFoundError(Exception):
|
||||
def __init__(self, internal_ip: str) -> None:
|
||||
self.internal_ip = internal_ip
|
||||
super().__init__(
|
||||
f"Client workstation not found in SAC hosts for internal_ip={internal_ip}. "
|
||||
"Ensure the PC is registered with matching ipv4."
|
||||
)
|
||||
|
||||
|
||||
def find_windows_host_by_ipv4(db: Session, ipv4: str) -> Host | None:
|
||||
ip = (ipv4 or "").strip()
|
||||
if not ip:
|
||||
return None
|
||||
rows = db.scalars(
|
||||
select(Host)
|
||||
.where(
|
||||
Host.ipv4 == ip,
|
||||
or_(
|
||||
Host.os_family.ilike("windows"),
|
||||
Host.product == "rdp-login-monitor",
|
||||
),
|
||||
)
|
||||
.order_by(Host.last_seen_at.desc())
|
||||
).all()
|
||||
for host in rows:
|
||||
if is_windows_host(host):
|
||||
return host
|
||||
return None
|
||||
|
||||
|
||||
def resolve_client_workstation(db: Session, event: Event) -> Host:
|
||||
internal_ip = event_internal_ip(event)
|
||||
if not internal_ip:
|
||||
raise ClientWorkstationNotFoundError("")
|
||||
host = find_windows_host_by_ipv4(db, internal_ip)
|
||||
if host is None:
|
||||
raise ClientWorkstationNotFoundError(internal_ip)
|
||||
return host
|
||||
@@ -0,0 +1,132 @@
|
||||
"""RD Gateway event labels (access path, UI title/summary)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Event
|
||||
from app.services.rdg_client_host import find_windows_host_by_ipv4
|
||||
from app.services.rdg_session_flap import event_internal_ip, resolve_rdg_qwinsta_enabled
|
||||
|
||||
RDG_TYPES = frozenset(
|
||||
{
|
||||
"rdg.connection.success",
|
||||
"rdg.connection.disconnected",
|
||||
"rdg.connection.failed",
|
||||
}
|
||||
)
|
||||
|
||||
ACCESS_PATH_HAPROXY = "Haproxy-RDG-Comp"
|
||||
ACCESS_PATH_DIRECT = "RDG-Comp"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RdgDisplayInfo:
|
||||
title: str
|
||||
summary: str
|
||||
access_path: str | None
|
||||
internal_ip: str | None
|
||||
qwinsta_enabled: bool
|
||||
|
||||
|
||||
def _details_dict(event: Event) -> dict:
|
||||
raw = event.details
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def _event_external_ip(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
for key in ("external_ip", "source_ip", "ip_address"):
|
||||
val = details.get(key)
|
||||
if val is not None and str(val).strip():
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_haproxy_ips() -> frozenset[str]:
|
||||
raw = (get_settings().sac_rdg_haproxy_external_ips or "").strip()
|
||||
if not raw:
|
||||
return frozenset()
|
||||
parts = [p.strip() for p in raw.replace(";", ",").split(",")]
|
||||
return frozenset(p for p in parts if p)
|
||||
|
||||
|
||||
def classify_rdg_access_path(external_ip: str) -> str | None:
|
||||
ip = (external_ip or "").strip()
|
||||
if not ip or ip in ("-", "N/A"):
|
||||
return None
|
||||
if ip in _parse_haproxy_ips():
|
||||
return ACCESS_PATH_HAPROXY
|
||||
return ACCESS_PATH_DIRECT
|
||||
|
||||
|
||||
def _windows_event_label(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
win_id = details.get("event_id_windows")
|
||||
if win_id is None:
|
||||
return ""
|
||||
text = str(win_id).strip()
|
||||
return f"event {text}" if text else ""
|
||||
|
||||
|
||||
def _action_label(event: Event) -> str:
|
||||
if event.type == "rdg.connection.success":
|
||||
return "подключение"
|
||||
if event.type == "rdg.connection.disconnected":
|
||||
return "отключение"
|
||||
return "ошибка"
|
||||
|
||||
|
||||
def build_rdg_display(event: Event, db: Session | None = None) -> RdgDisplayInfo | None:
|
||||
if event.type not in RDG_TYPES:
|
||||
return None
|
||||
|
||||
details = _details_dict(event)
|
||||
internal_ip = event_internal_ip(event)
|
||||
external_ip = _event_external_ip(event)
|
||||
access_path = classify_rdg_access_path(external_ip)
|
||||
user = str(details.get("user") or "").strip()
|
||||
gateway = event.host.hostname if event.host else "—"
|
||||
|
||||
client_label = internal_ip or "—"
|
||||
if db and internal_ip:
|
||||
client_host = find_windows_host_by_ipv4(db, internal_ip)
|
||||
if client_host is not None:
|
||||
client_label = f"{client_host.hostname} ({internal_ip})"
|
||||
|
||||
path_note = f" ({access_path})" if access_path else ""
|
||||
win_note = _windows_event_label(event)
|
||||
action = _action_label(event)
|
||||
|
||||
title = f"RDS {action} → {client_label}{path_note}"
|
||||
if win_note:
|
||||
title = f"{title} · {win_note}"
|
||||
|
||||
summary_parts: list[str] = []
|
||||
if user:
|
||||
summary_parts.append(user)
|
||||
if external_ip:
|
||||
summary_parts.append(f"внешний {external_ip}")
|
||||
summary_parts.append(f"шлюз {gateway}")
|
||||
if win_note:
|
||||
summary_parts.append(win_note)
|
||||
summary = " · ".join(summary_parts)
|
||||
|
||||
qwinsta_enabled = resolve_rdg_qwinsta_enabled(db, event)
|
||||
|
||||
return RdgDisplayInfo(
|
||||
title=title,
|
||||
summary=summary,
|
||||
access_path=access_path,
|
||||
internal_ip=internal_ip or None,
|
||||
qwinsta_enabled=qwinsta_enabled,
|
||||
)
|
||||
|
||||
|
||||
def event_supports_rdg_client_qwinsta(event: Event, db: Session | None = None) -> bool:
|
||||
from app.services.rdg_session_flap import resolve_rdg_qwinsta_enabled
|
||||
|
||||
return resolve_rdg_qwinsta_enabled(db, event)
|
||||
@@ -0,0 +1,311 @@
|
||||
"""RDG 302→303 session flap: detect, flag event, build Problem match."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Event
|
||||
from app.services.problem_rules import RULE_RDG_SESSION_FLAP, RuleMatch
|
||||
|
||||
RDG_SUCCESS_TYPE = "rdg.connection.success"
|
||||
RDG_END_TYPES = frozenset({"rdg.connection.disconnected", "rdg.connection.failed"})
|
||||
|
||||
|
||||
def _event_user(event: Event) -> str:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
return str(user).strip() if user else ""
|
||||
|
||||
|
||||
def _event_internal_ip(event: Event) -> str:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
for key in ("internal_ip", "client_ip", "ip_address"):
|
||||
val = details.get(key)
|
||||
if val:
|
||||
return str(val).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def event_internal_ip(event: Event) -> str:
|
||||
return _event_internal_ip(event)
|
||||
|
||||
|
||||
def _users_match(end_event: Event, success_event: Event) -> bool:
|
||||
return _event_user(end_event) != "" and _event_user(end_event) == _event_user(success_event)
|
||||
|
||||
|
||||
def _internal_ips_compatible(end_event: Event, success_event: Event) -> bool:
|
||||
end_ip = _event_internal_ip(end_event)
|
||||
success_ip = _event_internal_ip(success_event)
|
||||
if not end_ip or not success_ip:
|
||||
return True
|
||||
return end_ip == success_ip
|
||||
|
||||
|
||||
def _internal_ips_match_strict(end_event: Event, success_event: Event) -> bool:
|
||||
"""Для «сессия завершена» — только при совпадении целевого ПК (оба IP заданы)."""
|
||||
end_ip = _event_internal_ip(end_event)
|
||||
success_ip = _event_internal_ip(success_event)
|
||||
if not end_ip or not success_ip:
|
||||
return False
|
||||
return end_ip == success_ip
|
||||
|
||||
|
||||
def _as_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def find_rdg_success_before_end(db: Session, end_event: Event) -> Event | None:
|
||||
if end_event.type not in RDG_END_TYPES:
|
||||
return None
|
||||
settings = get_settings()
|
||||
min_sec = settings.sac_rdg_flap_window_min_sec
|
||||
max_sec = settings.sac_rdg_flap_window_max_sec
|
||||
end_at = _as_utc(end_event.occurred_at)
|
||||
|
||||
window_start = end_at - timedelta(seconds=max_sec)
|
||||
window_end = end_at - timedelta(seconds=min_sec)
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == end_event.host_id,
|
||||
Event.type == RDG_SUCCESS_TYPE,
|
||||
Event.occurred_at >= window_start,
|
||||
Event.occurred_at <= window_end,
|
||||
Event.id != end_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
for prior in candidates:
|
||||
if not _users_match(end_event, prior):
|
||||
continue
|
||||
if not _internal_ips_compatible(end_event, prior):
|
||||
continue
|
||||
prior_at = _as_utc(prior.occurred_at)
|
||||
delta = (end_at - prior_at).total_seconds()
|
||||
if min_sec <= delta <= max_sec:
|
||||
return prior
|
||||
return None
|
||||
|
||||
|
||||
def mark_rdg_flap(event: Event, *, pair_event: Event) -> None:
|
||||
details = dict(event.details) if isinstance(event.details, dict) else {}
|
||||
details["rdg_flap"] = True
|
||||
details["rdg_flap_pair_event_id"] = pair_event.id
|
||||
event.details = details
|
||||
flag_modified(event, "details")
|
||||
|
||||
|
||||
def evaluate_rdg_session_flap(db: Session, event: Event) -> RuleMatch | None:
|
||||
prior = find_rdg_success_before_end(db, event)
|
||||
if prior is None:
|
||||
return None
|
||||
|
||||
mark_rdg_flap(event, pair_event=prior)
|
||||
user = _event_user(event)
|
||||
internal_ip = _event_internal_ip(event)
|
||||
ip_note = f", client {internal_ip}" if internal_ip else ""
|
||||
delta_sec = int((_as_utc(event.occurred_at) - _as_utc(prior.occurred_at)).total_seconds())
|
||||
return RuleMatch(
|
||||
rule_id=RULE_RDG_SESSION_FLAP,
|
||||
correlation_type="rdg.session.flap",
|
||||
fingerprint_suffix=f"u{user}:ip{internal_ip or 'any'}",
|
||||
title=f"RDG session flap: {user}",
|
||||
summary=(
|
||||
f"302→303 за {delta_sec} с "
|
||||
f"({user}{ip_note}). Возможна зависшая сессия на ПК пользователя — qwinsta/logoff."
|
||||
),
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
|
||||
def event_has_rdg_flap(event: Event) -> bool:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
return details.get("rdg_flap") is True
|
||||
|
||||
|
||||
def _stored_flap_pair_id(event: Event) -> int | None:
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
raw = details.get("rdg_flap_pair_event_id")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def find_rdg_end_after_success(db: Session, success_event: Event) -> Event | None:
|
||||
"""303 с rdg_flap, у которого пара — этот 302 (или вычисляется по окну)."""
|
||||
if success_event.type != RDG_SUCCESS_TYPE:
|
||||
return None
|
||||
settings = get_settings()
|
||||
min_sec = settings.sac_rdg_flap_window_min_sec
|
||||
max_sec = settings.sac_rdg_flap_window_max_sec
|
||||
start_at = _as_utc(success_event.occurred_at)
|
||||
window_start = start_at + timedelta(seconds=min_sec)
|
||||
window_end = start_at + timedelta(seconds=max_sec)
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == success_event.host_id,
|
||||
Event.type.in_(RDG_END_TYPES),
|
||||
Event.occurred_at >= window_start,
|
||||
Event.occurred_at <= window_end,
|
||||
Event.id != success_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
).all()
|
||||
|
||||
for end in candidates:
|
||||
if not _users_match(end, success_event):
|
||||
continue
|
||||
if not _internal_ips_compatible(end, success_event):
|
||||
continue
|
||||
pair_id = _stored_flap_pair_id(end)
|
||||
if pair_id == success_event.id:
|
||||
return end
|
||||
prior = find_rdg_success_before_end(db, end)
|
||||
if prior is not None and prior.id == success_event.id:
|
||||
return end
|
||||
return None
|
||||
|
||||
|
||||
def find_normal_rdg_end_after_success(db: Session, success_event: Event) -> Event | None:
|
||||
"""303 после 302 с паузой больше flap-окна — штатное завершение сессии."""
|
||||
if success_event.type != RDG_SUCCESS_TYPE:
|
||||
return None
|
||||
settings = get_settings()
|
||||
max_sec = settings.sac_rdg_flap_window_max_sec
|
||||
start_at = _as_utc(success_event.occurred_at)
|
||||
after_flap = start_at + timedelta(seconds=max_sec)
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == success_event.host_id,
|
||||
Event.type.in_(RDG_END_TYPES),
|
||||
Event.occurred_at > after_flap,
|
||||
Event.id != success_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
).all()
|
||||
|
||||
for end in candidates:
|
||||
if not _users_match(end, success_event):
|
||||
continue
|
||||
if not _internal_ips_match_strict(end, success_event):
|
||||
continue
|
||||
return end
|
||||
return None
|
||||
|
||||
|
||||
def _users_and_ip_match(left: Event, right: Event) -> bool:
|
||||
return _users_match(left, right) and _internal_ips_match_strict(left, right)
|
||||
|
||||
|
||||
def find_later_rdg_success_after(
|
||||
db: Session,
|
||||
*,
|
||||
anchor: Event,
|
||||
after: datetime,
|
||||
) -> Event | None:
|
||||
"""Поздний 302 на том же шлюзе, user и client PC — пользователь снова зашёл через RDG."""
|
||||
if anchor.type != RDG_SUCCESS_TYPE:
|
||||
return None
|
||||
after_at = _as_utc(after)
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == anchor.host_id,
|
||||
Event.type == RDG_SUCCESS_TYPE,
|
||||
Event.occurred_at > after_at,
|
||||
Event.id != anchor.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
).all()
|
||||
|
||||
for later in candidates:
|
||||
if not _users_and_ip_match(later, anchor):
|
||||
continue
|
||||
return later
|
||||
return None
|
||||
|
||||
|
||||
def _flap_auto_disconnect_succeeded(flap_end: Event) -> bool:
|
||||
details = flap_end.details if isinstance(flap_end.details, dict) else {}
|
||||
block = details.get("rdp_flap_auto_disconnect")
|
||||
if not isinstance(block, dict):
|
||||
return False
|
||||
return block.get("ok") is True
|
||||
|
||||
|
||||
def _flap_workstation_session_closed(db: Session, flap_end: Event) -> bool:
|
||||
from app.services.host_sessions import event_session_terminated
|
||||
from app.services.rdg_workstation_session import find_workstation_login_for_rdg_end
|
||||
|
||||
login = find_workstation_login_for_rdg_end(db, flap_end)
|
||||
if login is None:
|
||||
return False
|
||||
return event_session_terminated(login, db=db)
|
||||
|
||||
|
||||
def resolve_rdg_qwinsta_enabled(db: Session | None, event: Event) -> bool:
|
||||
"""Кнопка qwinsta/logoff только на 302, пока сессия может быть активна (или RDG flap)."""
|
||||
if event.type in RDG_END_TYPES:
|
||||
return False
|
||||
if event.type != RDG_SUCCESS_TYPE:
|
||||
return False
|
||||
if not _event_internal_ip(event):
|
||||
return False
|
||||
if db is None:
|
||||
return True
|
||||
flap_end = find_rdg_end_after_success(db, event)
|
||||
if flap_end is not None:
|
||||
if _flap_auto_disconnect_succeeded(flap_end):
|
||||
return False
|
||||
if _flap_workstation_session_closed(db, flap_end):
|
||||
return False
|
||||
if find_later_rdg_success_after(db, anchor=event, after=flap_end.occurred_at) is not None:
|
||||
return False
|
||||
return True
|
||||
if find_normal_rdg_end_after_success(db, event) is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def resolve_rdg_flap_summary(
|
||||
db: Session, event: Event
|
||||
) -> tuple[bool, int | None, int | None]:
|
||||
"""
|
||||
(rdg_flap, pair_event_id, qwinsta_event_id).
|
||||
qwinsta_event_id — id события 302 для qwinsta (на 303 кнопку не показываем).
|
||||
"""
|
||||
if event_has_rdg_flap(event):
|
||||
pair_id = _stored_flap_pair_id(event)
|
||||
if event.type == RDG_SUCCESS_TYPE:
|
||||
return True, pair_id, event.id
|
||||
return True, pair_id, pair_id
|
||||
|
||||
if event.type in RDG_END_TYPES:
|
||||
prior = find_rdg_success_before_end(db, event)
|
||||
if prior is not None:
|
||||
return True, prior.id, prior.id
|
||||
|
||||
if event.type == RDG_SUCCESS_TYPE:
|
||||
end = find_rdg_end_after_success(db, event)
|
||||
if end is not None:
|
||||
return True, end.id, event.id
|
||||
|
||||
return False, None, None
|
||||
@@ -0,0 +1,182 @@
|
||||
"""RDG qwinsta/logoff via WinRM on client workstation (variant B)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AgentCommand, Event, Host
|
||||
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_session_flap import event_internal_ip
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
from app.services.winrm_connect import (
|
||||
WinRmCmdResult,
|
||||
run_winrm_logoff,
|
||||
run_winrm_on_host_targets,
|
||||
run_winrm_qwinsta,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
if not cfg.configured:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Windows admin is not configured (host override or Settings → Windows)",
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def _require_rdg_client_qwinsta(db: Session, event: Event) -> None:
|
||||
if event_supports_rdg_client_qwinsta(event, db):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Event is not an RD Gateway connection with internal_ip (client workstation)",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_client(db: Session, event: Event) -> Host:
|
||||
try:
|
||||
return resolve_client_workstation(db, event)
|
||||
except ClientWorkstationNotFoundError as exc:
|
||||
if not exc.internal_ip:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Event has no internal_ip (client workstation address)",
|
||||
) from exc
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _format_result_message(result: WinRmCmdResult, *, attempts: list[str]) -> str:
|
||||
message = result.message
|
||||
if attempts:
|
||||
message = f"{message} (пробовали: {', '.join(attempts)})"
|
||||
return message
|
||||
|
||||
|
||||
def _persist_command(
|
||||
db: Session,
|
||||
*,
|
||||
client_host: Host,
|
||||
event: Event,
|
||||
command_type: str,
|
||||
params: dict,
|
||||
requested_by: str,
|
||||
result: WinRmCmdResult,
|
||||
attempts: list[str],
|
||||
) -> AgentCommand:
|
||||
now = datetime.now(timezone.utc)
|
||||
cmd = AgentCommand(
|
||||
command_uuid=str(uuid.uuid4()),
|
||||
host_id=client_host.id,
|
||||
event_id=event.id,
|
||||
command_type=command_type,
|
||||
params=params,
|
||||
status="completed" if result.ok else "failed",
|
||||
result_stdout=result.stdout or None,
|
||||
result_stderr=(result.stderr or _format_result_message(result, attempts=attempts) or None)
|
||||
if not result.ok
|
||||
else None,
|
||||
requested_by=requested_by,
|
||||
created_at=now,
|
||||
completed_at=now,
|
||||
)
|
||||
db.add(cmd)
|
||||
db.flush()
|
||||
return cmd
|
||||
|
||||
|
||||
def execute_qwinsta_via_winrm(db: Session, event: Event, *, requested_by: str) -> AgentCommand:
|
||||
_require_rdg_client_qwinsta(db, event)
|
||||
client_host = _resolve_client(db, event)
|
||||
cfg = _require_win_admin(db, client_host)
|
||||
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
internal_ip = event_internal_ip(event)
|
||||
|
||||
result, attempts = run_winrm_on_host_targets(
|
||||
client_host,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
action=lambda target: run_winrm_qwinsta(target=target, user=cfg.user, password=cfg.password),
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
params = {
|
||||
"execution": "winrm",
|
||||
"user": user,
|
||||
"internal_ip": internal_ip,
|
||||
"client_hostname": client_host.hostname,
|
||||
"client_host_id": client_host.id,
|
||||
"winrm_target": result.target,
|
||||
}
|
||||
return _persist_command(
|
||||
db,
|
||||
client_host=client_host,
|
||||
event=event,
|
||||
command_type="qwinsta",
|
||||
params=params,
|
||||
requested_by=requested_by,
|
||||
result=result,
|
||||
attempts=attempts,
|
||||
)
|
||||
|
||||
|
||||
def execute_logoff_via_winrm(
|
||||
db: Session,
|
||||
event: Event,
|
||||
*,
|
||||
session_id: int,
|
||||
requested_by: str,
|
||||
) -> AgentCommand:
|
||||
_require_rdg_client_qwinsta(db, event)
|
||||
client_host = _resolve_client(db, event)
|
||||
cfg = _require_win_admin(db, client_host)
|
||||
|
||||
details = event.details if isinstance(event.details, dict) else {}
|
||||
user = details.get("user")
|
||||
internal_ip = event_internal_ip(event)
|
||||
|
||||
result, attempts = run_winrm_on_host_targets(
|
||||
client_host,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
action=lambda target: run_winrm_logoff(
|
||||
target=target,
|
||||
user=cfg.user,
|
||||
password=cfg.password,
|
||||
session_id=session_id,
|
||||
),
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
params = {
|
||||
"execution": "winrm",
|
||||
"user": user,
|
||||
"internal_ip": internal_ip,
|
||||
"client_hostname": client_host.hostname,
|
||||
"client_host_id": client_host.id,
|
||||
"session_id": session_id,
|
||||
"winrm_target": result.target,
|
||||
}
|
||||
return _persist_command(
|
||||
db,
|
||||
client_host=client_host,
|
||||
event=event,
|
||||
command_type="logoff",
|
||||
params=params,
|
||||
requested_by=requested_by,
|
||||
result=result,
|
||||
attempts=attempts,
|
||||
)
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Correlate RDG 303/303-failed with workstation rdp.login.success (1149)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.models import Event
|
||||
from app.services.host_sessions import (
|
||||
SESSION_TERMINATED_AT_KEY,
|
||||
_details_dict,
|
||||
_event_login_user,
|
||||
)
|
||||
from app.services.rdg_client_host import find_windows_host_by_ipv4
|
||||
from app.services.rdg_session_flap import (
|
||||
RDG_END_TYPES,
|
||||
RDG_SUCCESS_TYPE,
|
||||
_event_user,
|
||||
event_internal_ip,
|
||||
find_rdg_success_before_end,
|
||||
)
|
||||
|
||||
SESSION_CLOSED_BY_RDG_AT_KEY = "session_closed_by_rdg_at"
|
||||
SESSION_CLOSED_BY_RDG_EVENT_ID_KEY = "session_closed_by_rdg_event_id"
|
||||
USER_ENRICHED_FROM_RDG_EVENT_ID_KEY = "user_enriched_from_rdg_event_id"
|
||||
|
||||
# RCM 1149 via RD Gateway often has empty Param1/Param2; RDG 302 has the account.
|
||||
RDG_LOGIN_USER_ENRICH_WINDOW = timedelta(minutes=5)
|
||||
|
||||
WORKSTATION_LOGIN_TYPE = "rdp.login.success"
|
||||
|
||||
|
||||
def _as_utc(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def normalize_sam_account(user: str) -> str:
|
||||
text = (user or "").strip()
|
||||
if "\\" in text:
|
||||
return text.split("\\")[-1].strip().casefold()
|
||||
if "@" in text:
|
||||
return text.split("@")[0].strip().casefold()
|
||||
return text.casefold()
|
||||
|
||||
|
||||
def users_match_rdg(login_user: str, rdg_user: str) -> bool:
|
||||
left = normalize_sam_account(login_user)
|
||||
right = normalize_sam_account(rdg_user)
|
||||
return bool(left and right and left == right)
|
||||
|
||||
|
||||
def _login_user_missing(event: Event) -> bool:
|
||||
details = _details_dict(event)
|
||||
for key in ("user", "username"):
|
||||
val = details.get(key)
|
||||
if val is not None and str(val).strip() not in ("", "-"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _apply_rdg_user_to_login(login_event: Event, *, rdg_event: Event, rdg_user: str) -> None:
|
||||
details = dict(_details_dict(login_event))
|
||||
details["user"] = rdg_user
|
||||
details[USER_ENRICHED_FROM_RDG_EVENT_ID_KEY] = rdg_event.id
|
||||
login_event.details = details
|
||||
flag_modified(login_event, "details")
|
||||
summary = (login_event.summary or "").strip()
|
||||
if summary.startswith("RCM 1149") and rdg_user not in summary:
|
||||
rest = summary[len("RCM 1149") :].strip()
|
||||
login_event.summary = f"RCM 1149 {rdg_user} {rest}".strip()
|
||||
|
||||
|
||||
def find_rdg_success_for_workstation_login(db: Session, login_event: Event) -> Event | None:
|
||||
"""Nearest RDG 302 for this workstation IP within the enrich window."""
|
||||
if login_event.type != WORKSTATION_LOGIN_TYPE:
|
||||
return None
|
||||
host = login_event.host
|
||||
if host is None or not (host.ipv4 or "").strip():
|
||||
return None
|
||||
workstation_ip = host.ipv4.strip()
|
||||
login_at = _as_utc(login_event.occurred_at)
|
||||
window_start = login_at - RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
window_end = login_at + RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.type == RDG_SUCCESS_TYPE,
|
||||
Event.occurred_at >= window_start,
|
||||
Event.occurred_at <= window_end,
|
||||
Event.id != login_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
best: Event | None = None
|
||||
best_delta: timedelta | None = None
|
||||
for rdg in candidates:
|
||||
if event_internal_ip(rdg) != workstation_ip:
|
||||
continue
|
||||
if not _event_user(rdg):
|
||||
continue
|
||||
delta = abs(_as_utc(rdg.occurred_at) - login_at)
|
||||
if best is None or best_delta is None or delta < best_delta:
|
||||
best = rdg
|
||||
best_delta = delta
|
||||
return best
|
||||
|
||||
|
||||
def enrich_workstation_login_user_from_rdg(db: Session, login_event: Event) -> Event | None:
|
||||
"""Fill details.user when RCM 1149 EventLog left Param1/Param2 empty (seen on some Win10 Pro)."""
|
||||
if login_event.type != WORKSTATION_LOGIN_TYPE:
|
||||
return None
|
||||
if not _login_user_missing(login_event):
|
||||
return None
|
||||
rdg = find_rdg_success_for_workstation_login(db, login_event)
|
||||
if rdg is None:
|
||||
return None
|
||||
rdg_user = _event_user(rdg)
|
||||
if not rdg_user:
|
||||
return None
|
||||
_apply_rdg_user_to_login(login_event, rdg_event=rdg, rdg_user=rdg_user)
|
||||
return login_event
|
||||
|
||||
|
||||
def enrich_empty_login_from_rdg_success(db: Session, rdg_success_event: Event) -> Event | None:
|
||||
"""Backfill empty workstation 1149 when RDG 302 is ingested after it."""
|
||||
if rdg_success_event.type != RDG_SUCCESS_TYPE:
|
||||
return None
|
||||
internal_ip = event_internal_ip(rdg_success_event)
|
||||
rdg_user = _event_user(rdg_success_event)
|
||||
if not internal_ip or not rdg_user:
|
||||
return None
|
||||
client_host = find_windows_host_by_ipv4(db, internal_ip)
|
||||
if client_host is None:
|
||||
return None
|
||||
|
||||
rdg_at = _as_utc(rdg_success_event.occurred_at)
|
||||
window_start = rdg_at - RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
window_end = rdg_at + RDG_LOGIN_USER_ENRICH_WINDOW
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == client_host.id,
|
||||
Event.type == WORKSTATION_LOGIN_TYPE,
|
||||
Event.occurred_at >= window_start,
|
||||
Event.occurred_at <= window_end,
|
||||
Event.id != rdg_success_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
for login in candidates:
|
||||
if not _login_user_missing(login):
|
||||
continue
|
||||
_apply_rdg_user_to_login(login, rdg_event=rdg_success_event, rdg_user=rdg_user)
|
||||
return login
|
||||
return None
|
||||
|
||||
|
||||
def event_closed_by_rdg(event: Event) -> bool:
|
||||
details = _details_dict(event)
|
||||
at = details.get(SESSION_CLOSED_BY_RDG_AT_KEY)
|
||||
return at is not None and str(at).strip() != ""
|
||||
|
||||
|
||||
def mark_login_closed_by_rdg(login_event: Event, *, rdg_end_event: Event) -> None:
|
||||
details = dict(_details_dict(login_event))
|
||||
details[SESSION_CLOSED_BY_RDG_AT_KEY] = rdg_end_event.occurred_at.isoformat()
|
||||
details[SESSION_CLOSED_BY_RDG_EVENT_ID_KEY] = rdg_end_event.id
|
||||
login_event.details = details
|
||||
flag_modified(login_event, "details")
|
||||
|
||||
|
||||
def _login_already_closed(login_event: Event) -> bool:
|
||||
from app.services.rdp_session_logoff import event_closed_by_logoff
|
||||
|
||||
details = _details_dict(login_event)
|
||||
if details.get("session_terminated") is True:
|
||||
return True
|
||||
at = details.get(SESSION_TERMINATED_AT_KEY)
|
||||
if at is not None and str(at).strip() != "":
|
||||
return True
|
||||
if event_closed_by_rdg(login_event):
|
||||
return True
|
||||
return event_closed_by_logoff(login_event)
|
||||
|
||||
|
||||
def find_workstation_login_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
|
||||
if rdg_end_event.type not in RDG_END_TYPES:
|
||||
return None
|
||||
internal_ip = event_internal_ip(rdg_end_event)
|
||||
if not internal_ip:
|
||||
return None
|
||||
client_host = find_windows_host_by_ipv4(db, internal_ip)
|
||||
if client_host is None:
|
||||
return None
|
||||
|
||||
rdg_user = _event_user(rdg_end_event)
|
||||
if not rdg_user:
|
||||
return None
|
||||
end_at = rdg_end_event.occurred_at
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == client_host.id,
|
||||
Event.type == WORKSTATION_LOGIN_TYPE,
|
||||
Event.occurred_at <= end_at,
|
||||
Event.id != rdg_end_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
for login in candidates:
|
||||
if _login_already_closed(login):
|
||||
continue
|
||||
login_user = (_event_login_user(login) or "").strip()
|
||||
if login_user in ("", "-"):
|
||||
login_user = ""
|
||||
# RCM 1149 may lack user (empty Param1); still close by workstation IP + open session.
|
||||
if login_user and not users_match_rdg(login_user, rdg_user):
|
||||
continue
|
||||
return login
|
||||
return None
|
||||
|
||||
|
||||
def find_rdg_end_after_workstation_login(db: Session, login_event: Event) -> Event | None:
|
||||
"""Runtime lookup for historical events without persisted close flag."""
|
||||
if login_event.type != WORKSTATION_LOGIN_TYPE:
|
||||
return None
|
||||
if _login_already_closed(login_event):
|
||||
return None
|
||||
|
||||
host = login_event.host
|
||||
if host is None or not host.ipv4:
|
||||
return None
|
||||
|
||||
workstation_ip = host.ipv4.strip()
|
||||
login_user = _event_login_user(login_event)
|
||||
if not login_user:
|
||||
return None
|
||||
login_at = login_event.occurred_at
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.type.in_(RDG_END_TYPES),
|
||||
Event.occurred_at >= login_at,
|
||||
Event.id != login_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
).all()
|
||||
|
||||
for end in candidates:
|
||||
if event_internal_ip(end) != workstation_ip:
|
||||
continue
|
||||
if not users_match_rdg(_event_user(end), login_user):
|
||||
continue
|
||||
if find_rdg_success_before_end(db, end) is not None:
|
||||
continue
|
||||
return end
|
||||
return None
|
||||
|
||||
|
||||
def resolve_workstation_login_closed(db: Session, login_event: Event) -> bool:
|
||||
if event_closed_by_rdg(login_event):
|
||||
return True
|
||||
return find_rdg_end_after_workstation_login(db, login_event) is not None
|
||||
|
||||
|
||||
def close_workstation_session_for_rdg_end(db: Session, rdg_end_event: Event) -> Event | None:
|
||||
"""On RDG disconnect, mark matching workstation login as session-closed."""
|
||||
if rdg_end_event.type not in RDG_END_TYPES:
|
||||
return None
|
||||
if find_rdg_success_before_end(db, rdg_end_event) is not None:
|
||||
return None
|
||||
|
||||
login = find_workstation_login_for_rdg_end(db, rdg_end_event)
|
||||
if login is None:
|
||||
return None
|
||||
mark_login_closed_by_rdg(login, rdg_end_event=rdg_end_event)
|
||||
return login
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Short-lived RDP agent bundles for WinRM clients to download."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
_UTF8_BOM = b"\xef\xbb\xbf"
|
||||
_BOM_SUFFIXES = {".ps1", ".txt"}
|
||||
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,128}$")
|
||||
TTL_SECONDS = 600
|
||||
BUNDLE_DIR = Path("/tmp/sac-rdp-bundles")
|
||||
|
||||
|
||||
def _bundle_file_bytes(path: Path) -> bytes:
|
||||
data = path.read_bytes()
|
||||
if path.suffix.lower() in _BOM_SUFFIXES and not data.startswith(_UTF8_BOM):
|
||||
return _UTF8_BOM + data
|
||||
return data
|
||||
|
||||
|
||||
def build_rdp_bundle_zip(repo_dir: Path, filenames: tuple[str, ...]) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for name in filenames:
|
||||
path = repo_dir / name
|
||||
if path.is_file():
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.flag_bits |= 0x800
|
||||
archive.writestr(info, _bundle_file_bytes(path))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _prune_expired_bundles(now: float | None = None) -> None:
|
||||
if not BUNDLE_DIR.is_dir():
|
||||
return
|
||||
cutoff = (now or time.time()) - TTL_SECONDS
|
||||
for path in BUNDLE_DIR.glob("*.zip"):
|
||||
try:
|
||||
if path.stat().st_mtime < cutoff:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def register_rdp_bundle_zip(zip_bytes: bytes) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_prune_expired_bundles()
|
||||
(BUNDLE_DIR / f"{token}.zip").write_bytes(zip_bytes)
|
||||
return token
|
||||
|
||||
|
||||
def get_rdp_bundle_zip(token: str) -> bytes | None:
|
||||
text = (token or "").strip()
|
||||
if not _TOKEN_RE.fullmatch(text):
|
||||
return None
|
||||
path = BUNDLE_DIR / f"{text}.zip"
|
||||
if not path.is_file():
|
||||
return None
|
||||
if time.time() - path.stat().st_mtime > TTL_SECONDS:
|
||||
path.unlink(missing_ok=True)
|
||||
return None
|
||||
return path.read_bytes()
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Auto logoff stuck RDP sessions when RDG flap (or direct login failure) is detected."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.models import Event, Host
|
||||
from app.services.host_sessions import (
|
||||
list_windows_sessions,
|
||||
mark_event_session_terminated,
|
||||
parse_qwinsta_sessions,
|
||||
terminate_windows_session,
|
||||
)
|
||||
from app.services.rdg_client_host import ClientWorkstationNotFoundError, resolve_client_workstation
|
||||
from app.services.rdg_session_flap import (
|
||||
RDG_END_TYPES,
|
||||
RDG_SUCCESS_TYPE,
|
||||
_event_user,
|
||||
_stored_flap_pair_id,
|
||||
event_has_rdg_flap,
|
||||
find_rdg_success_before_end,
|
||||
)
|
||||
from app.services.rdg_winrm_actions import execute_logoff_via_winrm
|
||||
from app.services.rdg_workstation_session import (
|
||||
WORKSTATION_LOGIN_TYPE,
|
||||
_event_login_user,
|
||||
_login_already_closed,
|
||||
find_workstation_login_for_rdg_end,
|
||||
users_match_rdg,
|
||||
)
|
||||
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings
|
||||
from app.services.win_admin_settings import get_effective_win_admin_for_host
|
||||
|
||||
logger = logging.getLogger("sac.rdp_flap_auto_disconnect")
|
||||
|
||||
AUTO_DISCONNECT_BY = "auto:rdp_flap"
|
||||
RDP_LOGIN_FAILED = "rdp.login.failed"
|
||||
AUTO_DISCONNECT_DETAILS_KEY = "rdp_flap_auto_disconnect"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutoDisconnectResult:
|
||||
ok: bool
|
||||
message: str
|
||||
trigger_event_id: int
|
||||
workstation_host_id: int | None = None
|
||||
login_event_id: int | None = None
|
||||
session_ids: tuple[int, ...] = ()
|
||||
|
||||
|
||||
def _details_dict(event: Event) -> dict:
|
||||
raw = event.details
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def _already_auto_disconnected(event: Event) -> bool:
|
||||
details = _details_dict(event)
|
||||
block = details.get(AUTO_DISCONNECT_DETAILS_KEY)
|
||||
if not isinstance(block, dict):
|
||||
return False
|
||||
return block.get("ok") is True
|
||||
|
||||
|
||||
def _mark_auto_disconnect(event: Event, *, result: AutoDisconnectResult) -> None:
|
||||
details = dict(_details_dict(event))
|
||||
details[AUTO_DISCONNECT_DETAILS_KEY] = {
|
||||
"ok": result.ok,
|
||||
"message": result.message,
|
||||
"workstation_host_id": result.workstation_host_id,
|
||||
"login_event_id": result.login_event_id,
|
||||
"session_ids": list(result.session_ids),
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
event.details = details
|
||||
flag_modified(event, "details")
|
||||
|
||||
|
||||
def _norm_user_filter(user: str) -> str:
|
||||
text = (user or "").strip()
|
||||
if "\\" in text:
|
||||
return text.split("\\")[-1].strip().lower()
|
||||
if "@" in text:
|
||||
return text.split("@")[0].strip().lower()
|
||||
return text.lower()
|
||||
|
||||
|
||||
def _sessions_for_user(sessions, user: str):
|
||||
needle = _norm_user_filter(user)
|
||||
if not needle:
|
||||
return []
|
||||
matched = []
|
||||
for row in sessions:
|
||||
if needle in _norm_user_filter(row.user):
|
||||
matched.append(row)
|
||||
return matched
|
||||
|
||||
|
||||
def find_open_workstation_login(db: Session, *, host_id: int, user: str) -> Event | None:
|
||||
if not user.strip():
|
||||
return None
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == host_id,
|
||||
Event.type == WORKSTATION_LOGIN_TYPE,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
for login in candidates:
|
||||
if _login_already_closed(login):
|
||||
continue
|
||||
if users_match_rdg(_event_login_user(login), user):
|
||||
return login
|
||||
return None
|
||||
|
||||
|
||||
def _rdg_pair_success_event(db: Session, event: Event) -> Event | None:
|
||||
if event.type == RDG_SUCCESS_TYPE and event_has_rdg_flap(event):
|
||||
pair_id = _stored_flap_pair_id(event)
|
||||
if pair_id is not None:
|
||||
return db.get(Event, pair_id)
|
||||
return event
|
||||
if event.type in RDG_END_TYPES:
|
||||
pair_id = _stored_flap_pair_id(event)
|
||||
if pair_id is not None:
|
||||
return db.get(Event, pair_id)
|
||||
return find_rdg_success_before_end(db, event)
|
||||
return None
|
||||
|
||||
|
||||
def _disconnect_on_workstation(
|
||||
db: Session,
|
||||
*,
|
||||
trigger_event: Event,
|
||||
workstation: Host,
|
||||
rdg_event: Event | None,
|
||||
user: str,
|
||||
login_event: Event | None,
|
||||
) -> AutoDisconnectResult:
|
||||
win_cfg = get_effective_win_admin_for_host(db, workstation)
|
||||
sessions, qwinsta = list_windows_sessions(workstation, win_cfg)
|
||||
if qwinsta is None or not qwinsta.ok:
|
||||
message = qwinsta.message if qwinsta else "qwinsta failed"
|
||||
return AutoDisconnectResult(
|
||||
ok=False,
|
||||
message=message,
|
||||
trigger_event_id=trigger_event.id,
|
||||
workstation_host_id=workstation.id,
|
||||
login_event_id=login_event.id if login_event else None,
|
||||
)
|
||||
|
||||
parsed = parse_qwinsta_sessions(qwinsta.stdout, filter_user=user)
|
||||
matched = _sessions_for_user(parsed, user)
|
||||
if not matched and qwinsta.stdout.strip():
|
||||
parsed = parse_qwinsta_sessions(qwinsta.stdout)
|
||||
matched = _sessions_for_user(parsed, user)
|
||||
if not matched:
|
||||
snippet = " ".join(qwinsta.stdout.split())[:240]
|
||||
parsed_note = f", parsed={len(parsed)} session(s)" if parsed else ""
|
||||
message = (
|
||||
f"No matching Windows session for user {user} on {workstation.hostname}"
|
||||
f"{parsed_note}"
|
||||
)
|
||||
if snippet:
|
||||
message = f"{message}; qwinsta: {snippet}"
|
||||
return AutoDisconnectResult(
|
||||
ok=False,
|
||||
message=message,
|
||||
trigger_event_id=trigger_event.id,
|
||||
workstation_host_id=workstation.id,
|
||||
login_event_id=login_event.id if login_event else None,
|
||||
)
|
||||
|
||||
logged_off: list[int] = []
|
||||
errors: list[str] = []
|
||||
for row in matched:
|
||||
if rdg_event is not None:
|
||||
cmd = execute_logoff_via_winrm(
|
||||
db,
|
||||
rdg_event,
|
||||
session_id=int(row.session_id),
|
||||
requested_by=AUTO_DISCONNECT_BY,
|
||||
)
|
||||
if cmd.status == "completed":
|
||||
logged_off.append(int(row.session_id))
|
||||
else:
|
||||
errors.append(cmd.result_stderr or cmd.result_stdout or f"logoff {row.session_id} failed")
|
||||
else:
|
||||
result = terminate_windows_session(workstation, win_cfg, row.session_id)
|
||||
if result is not None and result.ok:
|
||||
logged_off.append(int(row.session_id))
|
||||
else:
|
||||
errors.append(result.message if result else f"logoff {row.session_id} failed")
|
||||
|
||||
if login_event is not None and logged_off:
|
||||
mark_event_session_terminated(login_event, by_username=AUTO_DISCONNECT_BY)
|
||||
|
||||
if logged_off:
|
||||
message = f"Auto logoff session(s) {', '.join(str(s) for s in logged_off)} on {workstation.hostname}"
|
||||
ok = True
|
||||
else:
|
||||
ok = False
|
||||
message = "; ".join(errors) if errors else "logoff failed"
|
||||
return AutoDisconnectResult(
|
||||
ok=ok,
|
||||
message=message,
|
||||
trigger_event_id=trigger_event.id,
|
||||
workstation_host_id=workstation.id,
|
||||
login_event_id=login_event.id if login_event else None,
|
||||
session_ids=tuple(logged_off),
|
||||
)
|
||||
|
||||
|
||||
def _auto_disconnect_rdg_flap(db: Session, event: Event) -> AutoDisconnectResult | None:
|
||||
if not event_has_rdg_flap(event):
|
||||
return None
|
||||
if _already_auto_disconnected(event):
|
||||
return None
|
||||
|
||||
rdg_success = _rdg_pair_success_event(db, event)
|
||||
if rdg_success is None:
|
||||
return None
|
||||
|
||||
user = _event_user(event) or _event_user(rdg_success)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
try:
|
||||
workstation = resolve_client_workstation(db, rdg_success)
|
||||
except ClientWorkstationNotFoundError as exc:
|
||||
result = AutoDisconnectResult(
|
||||
ok=False,
|
||||
message=str(exc),
|
||||
trigger_event_id=event.id,
|
||||
)
|
||||
_mark_auto_disconnect(event, result=result)
|
||||
return result
|
||||
|
||||
rdg_end = event if event.type in RDG_END_TYPES else db.get(Event, _stored_flap_pair_id(event) or -1)
|
||||
login_event = find_workstation_login_for_rdg_end(db, rdg_end) if rdg_end is not None else None
|
||||
|
||||
result = _disconnect_on_workstation(
|
||||
db,
|
||||
trigger_event=event,
|
||||
workstation=workstation,
|
||||
rdg_event=rdg_success,
|
||||
user=user,
|
||||
login_event=login_event,
|
||||
)
|
||||
_mark_auto_disconnect(event, result=result)
|
||||
return result
|
||||
|
||||
|
||||
def _auto_disconnect_direct_rdp_failed(db: Session, event: Event) -> AutoDisconnectResult | None:
|
||||
if event.type != RDP_LOGIN_FAILED:
|
||||
return None
|
||||
if _already_auto_disconnected(event):
|
||||
return None
|
||||
host = event.host
|
||||
if host is None:
|
||||
return None
|
||||
|
||||
user = _event_login_user(event)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
login_event = find_open_workstation_login(db, host_id=host.id, user=user)
|
||||
if login_event is None:
|
||||
return None
|
||||
|
||||
result = _disconnect_on_workstation(
|
||||
db,
|
||||
trigger_event=event,
|
||||
workstation=host,
|
||||
rdg_event=None,
|
||||
user=user,
|
||||
login_event=login_event,
|
||||
)
|
||||
_mark_auto_disconnect(event, result=result)
|
||||
return result
|
||||
|
||||
|
||||
def maybe_auto_disconnect_stuck_rdp_session(db: Session, event: Event) -> AutoDisconnectResult | None:
|
||||
"""Log off stuck user session when auto-disconnect is enabled."""
|
||||
if not get_effective_rdp_flap_settings(db).auto_disconnect:
|
||||
return None
|
||||
|
||||
result = _auto_disconnect_rdg_flap(db, event)
|
||||
if result is not None:
|
||||
if result.ok:
|
||||
logger.info(
|
||||
"auto rdp flap disconnect ok event_id=%s sessions=%s",
|
||||
event.event_id,
|
||||
result.session_ids,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"auto rdp flap disconnect failed event_id=%s: %s",
|
||||
event.event_id,
|
||||
result.message,
|
||||
)
|
||||
return result
|
||||
|
||||
result = _auto_disconnect_direct_rdp_failed(db, event)
|
||||
if result is not None:
|
||||
if result.ok:
|
||||
logger.info(
|
||||
"auto direct rdp disconnect ok event_id=%s sessions=%s",
|
||||
event.event_id,
|
||||
result.session_ids,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"auto direct rdp disconnect failed event_id=%s: %s",
|
||||
event.event_id,
|
||||
result.message,
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,33 @@
|
||||
"""RDP / RDG flap auto-disconnect settings (ui_settings singleton)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RdpFlapSettings:
|
||||
auto_disconnect: bool
|
||||
source: str = "db"
|
||||
|
||||
|
||||
def get_effective_rdp_flap_settings(db: Session) -> RdpFlapSettings:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
return RdpFlapSettings(auto_disconnect=False, source="default")
|
||||
return RdpFlapSettings(auto_disconnect=bool(row.auto_rdp_flap_disconnect), source="db")
|
||||
|
||||
|
||||
def upsert_rdp_flap_settings(db: Session, *, auto_disconnect: bool) -> RdpFlapSettings:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=True)
|
||||
db.add(row)
|
||||
row.auto_rdp_flap_disconnect = bool(auto_disconnect)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_rdp_flap_settings(db)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Correlate direct RDP logoff (Security 4634/4647) with workstation rdp.login.success."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.models import Event
|
||||
from app.services.host_sessions import (
|
||||
SESSION_TERMINATED_AT_KEY,
|
||||
_details_dict,
|
||||
_event_login_user,
|
||||
)
|
||||
from app.services.rdg_workstation_session import (
|
||||
WORKSTATION_LOGIN_TYPE,
|
||||
event_closed_by_rdg,
|
||||
users_match_rdg,
|
||||
)
|
||||
|
||||
SESSION_CLOSED_BY_LOGOFF_AT_KEY = "session_closed_by_logoff_at"
|
||||
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY = "session_closed_by_logoff_event_id"
|
||||
|
||||
LOGOFF_EVENT_TYPE = "rdp.session.logoff"
|
||||
LOGOFF_EVENT_TYPES = frozenset({LOGOFF_EVENT_TYPE})
|
||||
|
||||
|
||||
def event_closed_by_logoff(event: Event) -> bool:
|
||||
details = _details_dict(event)
|
||||
at = details.get(SESSION_CLOSED_BY_LOGOFF_AT_KEY)
|
||||
return at is not None and str(at).strip() != ""
|
||||
|
||||
|
||||
def mark_login_closed_by_logoff(login_event: Event, *, logoff_event: Event) -> None:
|
||||
details = dict(_details_dict(login_event))
|
||||
details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] = logoff_event.occurred_at.isoformat()
|
||||
details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] = logoff_event.id
|
||||
login_event.details = details
|
||||
flag_modified(login_event, "details")
|
||||
|
||||
|
||||
def _login_already_closed(login_event: Event) -> bool:
|
||||
details = _details_dict(login_event)
|
||||
if details.get("session_terminated") is True:
|
||||
return True
|
||||
at = details.get(SESSION_TERMINATED_AT_KEY)
|
||||
if at is not None and str(at).strip() != "":
|
||||
return True
|
||||
if event_closed_by_rdg(login_event):
|
||||
return True
|
||||
return event_closed_by_logoff(login_event)
|
||||
|
||||
|
||||
def find_workstation_login_for_logoff(db: Session, logoff_event: Event) -> Event | None:
|
||||
if logoff_event.type not in LOGOFF_EVENT_TYPES:
|
||||
return None
|
||||
|
||||
logoff_user = _event_login_user(logoff_event)
|
||||
if not logoff_user:
|
||||
return None
|
||||
logoff_at = logoff_event.occurred_at
|
||||
host_id = logoff_event.host_id
|
||||
if host_id is None:
|
||||
return None
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == host_id,
|
||||
Event.type == WORKSTATION_LOGIN_TYPE,
|
||||
Event.occurred_at <= logoff_at,
|
||||
Event.id != logoff_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.desc())
|
||||
).all()
|
||||
|
||||
logoff_details = _details_dict(logoff_event)
|
||||
logoff_ip = str(logoff_details.get("ip_address") or "").strip()
|
||||
|
||||
for login in candidates:
|
||||
if _login_already_closed(login):
|
||||
continue
|
||||
login_user = _event_login_user(login)
|
||||
if not users_match_rdg(login_user, logoff_user):
|
||||
continue
|
||||
if logoff_ip and logoff_ip not in ("", "-"):
|
||||
login_ip = str(_details_dict(login).get("ip_address") or "").strip()
|
||||
if login_ip and login_ip not in ("", "-") and login_ip != logoff_ip:
|
||||
continue
|
||||
return login
|
||||
return None
|
||||
|
||||
|
||||
def find_logoff_after_workstation_login(db: Session, login_event: Event) -> Event | None:
|
||||
"""Runtime lookup for historical logins without persisted close flag."""
|
||||
if login_event.type != WORKSTATION_LOGIN_TYPE:
|
||||
return None
|
||||
if _login_already_closed(login_event):
|
||||
return None
|
||||
|
||||
host_id = login_event.host_id
|
||||
if host_id is None:
|
||||
return None
|
||||
|
||||
login_user = _event_login_user(login_event)
|
||||
if not login_user:
|
||||
return None
|
||||
login_at = login_event.occurred_at
|
||||
login_ip = str(_details_dict(login_event).get("ip_address") or "").strip()
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Event)
|
||||
.where(
|
||||
Event.host_id == host_id,
|
||||
Event.type == LOGOFF_EVENT_TYPE,
|
||||
Event.occurred_at >= login_at,
|
||||
Event.id != login_event.id,
|
||||
)
|
||||
.order_by(Event.occurred_at.asc())
|
||||
).all()
|
||||
|
||||
for logoff in candidates:
|
||||
if not users_match_rdg(_event_login_user(logoff), login_user):
|
||||
continue
|
||||
logoff_ip = str(_details_dict(logoff).get("ip_address") or "").strip()
|
||||
if login_ip and login_ip not in ("", "-") and logoff_ip and logoff_ip not in ("", "-"):
|
||||
if login_ip != logoff_ip:
|
||||
continue
|
||||
return logoff
|
||||
return None
|
||||
|
||||
|
||||
def resolve_workstation_login_closed_by_logoff(db: Session, login_event: Event) -> bool:
|
||||
if event_closed_by_logoff(login_event):
|
||||
return True
|
||||
return find_logoff_after_workstation_login(db, login_event) is not None
|
||||
|
||||
|
||||
def close_workstation_session_for_rdp_logoff(db: Session, logoff_event: Event) -> Event | None:
|
||||
"""On direct RDP logoff, mark matching open workstation login as session-closed."""
|
||||
if logoff_event.type not in LOGOFF_EVENT_TYPES:
|
||||
return None
|
||||
|
||||
login = find_workstation_login_for_logoff(db, logoff_event)
|
||||
if login is None:
|
||||
return None
|
||||
mark_login_closed_by_logoff(login, logoff_event=logoff_event)
|
||||
return login
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Удаление устаревших events и resolved problems."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import Event, Problem
|
||||
|
||||
|
||||
def purge_old_data(db: Session, settings: Settings) -> dict:
|
||||
"""
|
||||
Events: received_at старше sac_events_retention_days.
|
||||
Problems: status=resolved и updated_at старше sac_problems_retention_days.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
events_cutoff = now - timedelta(days=settings.sac_events_retention_days)
|
||||
problems_cutoff = now - timedelta(days=settings.sac_problems_retention_days)
|
||||
|
||||
events_result = db.execute(delete(Event).where(Event.received_at < events_cutoff))
|
||||
events_deleted = events_result.rowcount or 0
|
||||
|
||||
problems_result = db.execute(
|
||||
delete(Problem).where(
|
||||
Problem.status == "resolved",
|
||||
Problem.updated_at < problems_cutoff,
|
||||
)
|
||||
)
|
||||
problems_deleted = problems_result.rowcount or 0
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"events_deleted": events_deleted,
|
||||
"problems_deleted": problems_deleted,
|
||||
"events_cutoff": events_cutoff.isoformat(),
|
||||
"problems_cutoff": problems_cutoff.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import jsonschema
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_event_validator() -> Draft202012Validator:
|
||||
path = Path(get_settings().event_schema_path)
|
||||
if not path.is_file():
|
||||
path = Path(__file__).resolve().parents[3] / "schemas" / "event-schema-v1.json"
|
||||
schema = json.loads(path.read_text(encoding="utf-8"))
|
||||
Draft202012Validator.check_schema(schema)
|
||||
return Draft202012Validator(schema, format_checker=Draft202012Validator.FORMAT_CHECKER)
|
||||
|
||||
|
||||
def validate_event_payload(payload: dict) -> list[str]:
|
||||
validator = get_event_validator()
|
||||
errors = sorted(validator.iter_errors(payload), key=lambda e: e.path)
|
||||
return [e.message for e in errors]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Format and extract RDP/RDG session_duration_sec for UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def extract_session_duration_sec(details: dict[str, Any] | None) -> int | None:
|
||||
if not isinstance(details, dict):
|
||||
return None
|
||||
raw = details.get("session_duration_sec")
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if value < 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def format_session_duration(seconds: int) -> str:
|
||||
"""
|
||||
Human-readable session length for lists:
|
||||
- under 24h → HH:MM:SS (05:05:24)
|
||||
- 24h+ → Nд HH:MM:SS (1д 00:01:25)
|
||||
"""
|
||||
if seconds < 0:
|
||||
return "—"
|
||||
days, rem = divmod(int(seconds), 86_400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes, secs = divmod(rem, 60)
|
||||
clock = f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
if days:
|
||||
return f"{days}д {clock}"
|
||||
return clock
|
||||
@@ -0,0 +1,588 @@
|
||||
"""SSH connectivity and remote commands for Linux hosts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Host
|
||||
|
||||
SSH_MONITOR_UPDATE_STATE_FILE = "/var/lib/ssh-monitor/agent-update-in-progress"
|
||||
SSH_MONITOR_UPDATE_LOG_PATH = "/var/log/update_script.log"
|
||||
SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
|
||||
SSH_MONITOR_UPDATE_DIR = "/opt/scripts/update"
|
||||
SSH_MONITOR_REPO_NAME = "ssh-monitor"
|
||||
SSH_MONITOR_UPDATE_REPO_URL = "https://git.papatramp.ru/PapaTramp/ssh-monitor.git"
|
||||
SSH_MONITOR_BINARY = "/usr/local/bin/ssh-monitor"
|
||||
SSH_MONITOR_VERSION_CMD = (
|
||||
f"grep -m1 '^SSH_MONITOR_VERSION=' {SSH_MONITOR_BINARY} 2>/dev/null "
|
||||
"| sed -E 's/^SSH_MONITOR_VERSION=[\"'\\'']*([^\"'\\'']+)[\"'\\'']*$/\\1/'"
|
||||
)
|
||||
SSH_OUTPUT_MAX_LEN = 12_000
|
||||
_AGENT_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:-SAC)?)", re.IGNORECASE)
|
||||
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||||
_SSH_TRANSIENT_ERROR_MARKERS = (
|
||||
"no existing session",
|
||||
"error reading ssh protocol banner",
|
||||
"connection reset",
|
||||
"connection lost",
|
||||
"session not active",
|
||||
"eof",
|
||||
"socket is closed",
|
||||
"connection timed out",
|
||||
)
|
||||
_SSH_CONNECT_ATTEMPTS = 2
|
||||
|
||||
|
||||
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
|
||||
agent_version: str | 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 _is_ipv4(value: str) -> bool:
|
||||
return bool(_IPV4_RE.match(value.strip()))
|
||||
|
||||
|
||||
def _looks_like_ssh_hostname(value: str) -> bool:
|
||||
"""Короткое имя или FQDN; не человекочитаемый display_name вроде «Ubabuba Kalina (10.10.36.9)»."""
|
||||
text = value.strip()
|
||||
if not text or " " in text or "(" in text or ")" in text:
|
||||
return False
|
||||
if _is_ipv4(text):
|
||||
return True
|
||||
if len(text) > 253:
|
||||
return False
|
||||
return all(ch.isalnum() or ch in ".-_" for ch in text)
|
||||
|
||||
|
||||
def _ssh_name_resolves(name: str) -> bool:
|
||||
if _is_ipv4(name):
|
||||
return True
|
||||
try:
|
||||
socket.getaddrinfo(name.strip(), 22, type=socket.SOCK_STREAM)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
if not _looks_like_ssh_hostname(text):
|
||||
return
|
||||
if not _ssh_name_resolves(text):
|
||||
return
|
||||
seen.add(text.casefold())
|
||||
ordered.append(text)
|
||||
|
||||
add(host.hostname)
|
||||
|
||||
inventory = host.inventory if isinstance(host.inventory, dict) else {}
|
||||
computer_name = inventory.get("computer_name")
|
||||
if isinstance(computer_name, str):
|
||||
add(computer_name)
|
||||
|
||||
if host.display_name and _looks_like_ssh_hostname(host.display_name):
|
||||
add(host.display_name)
|
||||
|
||||
add(host.ipv4)
|
||||
if not ordered:
|
||||
raise HostTargetMissingError("Host has no resolvable 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 _is_transient_ssh_error(exc: BaseException) -> bool:
|
||||
text = str(exc).casefold()
|
||||
return any(marker in text for marker in _SSH_TRANSIENT_ERROR_MARKERS)
|
||||
|
||||
|
||||
def _ssh_connect_hint(exc: BaseException) -> str:
|
||||
text = str(exc).casefold()
|
||||
if "no existing session" in text:
|
||||
return (
|
||||
" Сервер закрыл SSH-сессию во время подключения или auth "
|
||||
"(проверьте пароль admin, лимиты sshd MaxSessions/MaxStartups, "
|
||||
"доступ SAC→хост по 22/tcp; на SAC не должен мешать ssh-agent)."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _connect_ssh_client(
|
||||
client,
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
connect_timeout_sec: int,
|
||||
) -> None:
|
||||
client.connect(
|
||||
hostname=target,
|
||||
username=user,
|
||||
password=password,
|
||||
timeout=connect_timeout_sec,
|
||||
banner_timeout=connect_timeout_sec,
|
||||
auth_timeout=connect_timeout_sec,
|
||||
allow_agent=False,
|
||||
look_for_keys=False,
|
||||
compress=False,
|
||||
)
|
||||
|
||||
|
||||
def _close_ssh_client(client) -> None:
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
transport = client.get_transport()
|
||||
if transport is not None and transport.is_active():
|
||||
transport.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _shell_single_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _remote_shell_command(
|
||||
user: str,
|
||||
remote_cmd: str,
|
||||
*,
|
||||
need_root: bool = False,
|
||||
login_shell: bool = True,
|
||||
) -> tuple[str, bool]:
|
||||
"""Build remote shell command. Returns (command, needs_sudo_password_on_stdin)."""
|
||||
safe_cmd = _shell_single_quote(remote_cmd)
|
||||
bash_flag = "lc" if login_shell else "c"
|
||||
if user == "root" or not need_root:
|
||||
return f"bash -{bash_flag} {safe_cmd}", False
|
||||
return f"sudo -S -p '' bash -{bash_flag} {safe_cmd}", True
|
||||
|
||||
|
||||
def _configure_ssh_client(client, target: str) -> None:
|
||||
import paramiko
|
||||
|
||||
settings = get_settings()
|
||||
if settings.sac_ssh_auto_add_host_key:
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
return
|
||||
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
client.load_system_host_keys()
|
||||
known_hosts = (settings.sac_ssh_known_hosts_file or "").strip()
|
||||
if known_hosts:
|
||||
try:
|
||||
client.load_host_keys(known_hosts)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _exec_remote_command(
|
||||
client,
|
||||
*,
|
||||
shell_cmd: str,
|
||||
password: str,
|
||||
needs_sudo_password: bool,
|
||||
command_timeout_sec: int,
|
||||
) -> tuple[int, str, str]:
|
||||
if needs_sudo_password:
|
||||
stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec, get_pty=True)
|
||||
stdin.write(f"{password}\n")
|
||||
stdin.flush()
|
||||
stdin.channel.shutdown_write()
|
||||
else:
|
||||
_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"))
|
||||
return exit_code, out_text, err_text
|
||||
|
||||
|
||||
def run_ssh_command(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
remote_cmd: str,
|
||||
connect_timeout_sec: int = 15,
|
||||
command_timeout_sec: int = 600,
|
||||
need_root: bool = False,
|
||||
login_shell: bool = True,
|
||||
) -> 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
|
||||
|
||||
shell_cmd, needs_sudo_password = _remote_shell_command(
|
||||
user,
|
||||
remote_cmd,
|
||||
need_root=need_root,
|
||||
login_shell=login_shell,
|
||||
)
|
||||
exit_code: int | None = None
|
||||
out_text = ""
|
||||
err_text = ""
|
||||
|
||||
for attempt in range(1, _SSH_CONNECT_ATTEMPTS + 1):
|
||||
client = paramiko.SSHClient()
|
||||
try:
|
||||
_configure_ssh_client(client, target)
|
||||
_connect_ssh_client(
|
||||
client,
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
connect_timeout_sec=connect_timeout_sec,
|
||||
)
|
||||
exit_code, out_text, err_text = _exec_remote_command(
|
||||
client,
|
||||
shell_cmd=shell_cmd,
|
||||
password=password,
|
||||
needs_sudo_password=needs_sudo_password,
|
||||
command_timeout_sec=command_timeout_sec,
|
||||
)
|
||||
break
|
||||
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:
|
||||
if attempt < _SSH_CONNECT_ATTEMPTS and _is_transient_ssh_error(exc):
|
||||
continue
|
||||
hint = _ssh_connect_hint(exc)
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH error ({target}): {exc}{hint}",
|
||||
target=target,
|
||||
)
|
||||
finally:
|
||||
_close_ssh_client(client)
|
||||
else:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH error ({target}): repeated transient connection failures",
|
||||
target=target,
|
||||
)
|
||||
|
||||
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 _ssh_output_hostname(stdout: str) -> str:
|
||||
"""Last line that looks like a hostname (MOTD/login banners may precede command output)."""
|
||||
candidates = [ln.strip() for ln in stdout.splitlines() if ln.strip()]
|
||||
for line in reversed(candidates):
|
||||
if re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,253}$", line):
|
||||
return line
|
||||
return candidates[-1] if candidates else ""
|
||||
|
||||
|
||||
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,
|
||||
login_shell=False,
|
||||
)
|
||||
if result.ok and result.stdout.strip():
|
||||
host = _ssh_output_hostname(result.stdout)
|
||||
if not host:
|
||||
return result
|
||||
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 parse_ssh_monitor_version_text(text: str) -> str | None:
|
||||
if not text:
|
||||
return None
|
||||
match = _AGENT_VERSION_RE.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def probe_ssh_monitor_version(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
) -> str | None:
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=SSH_MONITOR_VERSION_CMD,
|
||||
command_timeout_sec=30,
|
||||
login_shell=False,
|
||||
)
|
||||
if result.ok and result.stdout.strip():
|
||||
version = parse_ssh_monitor_version_text(result.stdout)
|
||||
if version:
|
||||
return version
|
||||
if result.stdout or result.stderr:
|
||||
return parse_ssh_monitor_version_text(f"{result.stdout}\n{result.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def _ssh_monitor_preflight_git_remote(repo_url: str) -> str:
|
||||
"""Align clone remotes with SAC-trusted URL (works even before updater script is refreshed)."""
|
||||
safe_repo = _shell_single_quote(repo_url.strip())
|
||||
repo_dir = _shell_single_quote(f"{SSH_MONITOR_UPDATE_DIR}/{SSH_MONITOR_REPO_NAME}")
|
||||
return (
|
||||
f"if [ -d {repo_dir}/.git ]; then "
|
||||
f"git -C {repo_dir} remote add kalinamall {safe_repo} 2>/dev/null || "
|
||||
f"git -C {repo_dir} remote set-url kalinamall {safe_repo}; "
|
||||
f"if git -C {repo_dir} remote get-url origin >/dev/null 2>&1; then "
|
||||
f"git -C {repo_dir} remote set-url origin {safe_repo}; "
|
||||
f"fi; fi"
|
||||
)
|
||||
|
||||
|
||||
def _ssh_monitor_update_invoke_command(
|
||||
repo_url: str,
|
||||
*,
|
||||
git_branch: str = "main",
|
||||
) -> str:
|
||||
"""Run installed updater with SAC-trusted git URL (overrides script default / stale origin)."""
|
||||
safe_repo = _shell_single_quote(repo_url.strip())
|
||||
safe_branch = _shell_single_quote((git_branch or "main").strip() or "main")
|
||||
preflight = _ssh_monitor_preflight_git_remote(repo_url)
|
||||
return f"{preflight}; UPDATE_VIA_SAC=1 REPO_URL={safe_repo} GIT_BRANCH={safe_branch} {SSH_MONITOR_UPDATE_SCRIPT}"
|
||||
|
||||
|
||||
def _ssh_monitor_bootstrap_command(repo_url: str, *, git_branch: str = "main") -> str:
|
||||
"""Clone ssh-monitor repo and install updater when /opt/scripts/update_ssh_monitor.sh is absent."""
|
||||
safe_repo = repo_url.replace("\\", "\\\\").replace('"', '\\"')
|
||||
safe_branch = (git_branch or "main").replace("\\", "\\\\").replace('"', '\\"')
|
||||
return (
|
||||
f'UPDATE_DIR="{SSH_MONITOR_UPDATE_DIR}";'
|
||||
f'REPO_URL="{safe_repo}";'
|
||||
f'GIT_BRANCH="{safe_branch}";'
|
||||
f'SCRIPT_NAME="{SSH_MONITOR_REPO_NAME}";'
|
||||
f'INSTALL="{SSH_MONITOR_UPDATE_SCRIPT}";'
|
||||
f'mkdir -p "$UPDATE_DIR" && cd "$UPDATE_DIR" && '
|
||||
f'([ -d "$SCRIPT_NAME" ] || git clone -b "$GIT_BRANCH" "$REPO_URL" "$SCRIPT_NAME") && '
|
||||
f'cp "$UPDATE_DIR/$SCRIPT_NAME/update_ssh_monitor.sh" "$INSTALL" && '
|
||||
f'chmod 750 "$INSTALL" && UPDATE_VIA_SAC=1 REPO_URL="$REPO_URL" GIT_BRANCH="$GIT_BRANCH" "$INSTALL" --deploy'
|
||||
)
|
||||
|
||||
|
||||
def _ssh_monitor_mark_update_begin_prefix() -> str:
|
||||
"""State-file до updater: lifecycle/sudo на агенте глушатся раньше bootstrap."""
|
||||
return (
|
||||
f"mkdir -p /var/lib/ssh-monitor && "
|
||||
f"date +%s > {SSH_MONITOR_UPDATE_STATE_FILE} && "
|
||||
f"chmod 600 {SSH_MONITOR_UPDATE_STATE_FILE} 2>/dev/null; "
|
||||
)
|
||||
|
||||
|
||||
def run_ssh_monitor_update(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
repo_url: str | None = None,
|
||||
git_branch: str | None = None,
|
||||
) -> SshCommandResult:
|
||||
script = SSH_MONITOR_UPDATE_SCRIPT
|
||||
effective_repo = (repo_url or SSH_MONITOR_UPDATE_REPO_URL).strip()
|
||||
effective_branch = (git_branch or "main").strip() or "main"
|
||||
update_cmd = _ssh_monitor_update_invoke_command(
|
||||
effective_repo,
|
||||
git_branch=effective_branch,
|
||||
)
|
||||
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,
|
||||
login_shell=False,
|
||||
)
|
||||
if not probe.ok:
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"SSH probe failed on {target}: {probe.message}",
|
||||
target=target,
|
||||
stdout=probe.stdout,
|
||||
stderr=probe.stderr,
|
||||
exit_code=probe.exit_code,
|
||||
)
|
||||
|
||||
bootstrapped = False
|
||||
if "missing" in probe.stdout:
|
||||
bootstrap_cmd = _ssh_monitor_bootstrap_command(
|
||||
effective_repo,
|
||||
git_branch=effective_branch,
|
||||
)
|
||||
updated = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + bootstrap_cmd,
|
||||
command_timeout_sec=900,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
bootstrapped = True
|
||||
else:
|
||||
updated = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=_ssh_monitor_mark_update_begin_prefix() + update_cmd,
|
||||
command_timeout_sec=900,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
if not updated.ok:
|
||||
prefix = "Updater bootstrap failed" if bootstrapped else "SSH update failed"
|
||||
return SshCommandResult(
|
||||
ok=False,
|
||||
message=f"{prefix} ({target}): {updated.message}",
|
||||
target=updated.target,
|
||||
stdout=updated.stdout,
|
||||
stderr=updated.stderr,
|
||||
exit_code=updated.exit_code,
|
||||
)
|
||||
|
||||
version = probe_ssh_monitor_version(target=target, user=user, password=password)
|
||||
if not version:
|
||||
version = parse_ssh_monitor_version_text(updated.stdout)
|
||||
if version:
|
||||
prefix = "Bootstrap + update OK" if bootstrapped else updated.message
|
||||
message = f"{prefix}\nagent version: {version}"
|
||||
return SshCommandResult(
|
||||
ok=True,
|
||||
message=message,
|
||||
target=updated.target,
|
||||
stdout=updated.stdout,
|
||||
stderr=updated.stderr,
|
||||
exit_code=updated.exit_code,
|
||||
agent_version=version,
|
||||
)
|
||||
if bootstrapped:
|
||||
return SshCommandResult(
|
||||
ok=True,
|
||||
message=f"Bootstrap + update OK ({target}), exit 0",
|
||||
target=updated.target,
|
||||
stdout=updated.stdout,
|
||||
stderr=updated.stderr,
|
||||
exit_code=updated.exit_code,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
def tail_ssh_monitor_update_log(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
lines: int = 120,
|
||||
) -> str:
|
||||
"""Хвост /var/log/update_script.log на удалённом хосте (для live-лога в SAC UI)."""
|
||||
safe_lines = max(20, min(int(lines), 400))
|
||||
remote_cmd = (
|
||||
f"test -r {SSH_MONITOR_UPDATE_LOG_PATH} && "
|
||||
f"tail -n {safe_lines} {SSH_MONITOR_UPDATE_LOG_PATH} 2>/dev/null || true"
|
||||
)
|
||||
result = run_ssh_command(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=remote_cmd,
|
||||
command_timeout_sec=25,
|
||||
need_root=True,
|
||||
login_shell=False,
|
||||
)
|
||||
text = (result.stdout or "").strip()
|
||||
return _truncate_output(text)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Host and DB metrics for SAC sidebar (best-effort, no heavy queries)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Problem
|
||||
|
||||
|
||||
def _disk_path() -> str:
|
||||
explicit = os.environ.get("SAC_STATS_DISK_PATH", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
return str(Path("/").resolve())
|
||||
|
||||
|
||||
def _read_cpu_percent() -> float | None:
|
||||
try:
|
||||
import psutil # type: ignore[import-untyped]
|
||||
|
||||
return round(float(psutil.cpu_percent(interval=None)), 1)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _read_memory() -> dict[str, float | int] | None:
|
||||
try:
|
||||
import psutil # type: ignore[import-untyped]
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
return {
|
||||
"used_mb": int(vm.used / (1024 * 1024)),
|
||||
"total_mb": int(vm.total / (1024 * 1024)),
|
||||
"percent": round(float(vm.percent), 1),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _read_disk(path: str) -> dict[str, float | int | str] | None:
|
||||
try:
|
||||
usage = shutil.disk_usage(path)
|
||||
total = int(usage.total)
|
||||
used = int(usage.used)
|
||||
if total <= 0:
|
||||
return None
|
||||
return {
|
||||
"path": path,
|
||||
"used_gb": round(used / (1024**3), 1),
|
||||
"total_gb": round(total / (1024**3), 1),
|
||||
"percent": round(100.0 * used / total, 1),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def collect_system_stats(db: Session) -> dict:
|
||||
collected_at = datetime.now(timezone.utc).isoformat()
|
||||
disk_path = _disk_path()
|
||||
|
||||
db_status = "ok"
|
||||
db_latency_ms: float | None = None
|
||||
db_connections: int | None = None
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
db_latency_ms = round((time.perf_counter() - t0) * 1000, 1)
|
||||
try:
|
||||
db_connections = int(
|
||||
db.scalar(
|
||||
text(
|
||||
"SELECT count(*) FROM pg_stat_activity "
|
||||
"WHERE datname = current_database() AND pid <> pg_backend_pid()"
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
except Exception:
|
||||
db_connections = None
|
||||
except Exception:
|
||||
db_status = "error"
|
||||
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
events_last_hour = 0
|
||||
problems_open = 0
|
||||
if db_status == "ok":
|
||||
events_last_hour = int(
|
||||
db.scalar(select(func.count()).select_from(Event).where(Event.received_at >= since)) or 0
|
||||
)
|
||||
problems_open = int(
|
||||
db.scalar(select(func.count()).select_from(Problem).where(Problem.status == "open")) or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"collected_at": collected_at,
|
||||
"cpu_percent": _read_cpu_percent(),
|
||||
"memory": _read_memory(),
|
||||
"disk": _read_disk(disk_path),
|
||||
"database": {
|
||||
"status": db_status,
|
||||
"latency_ms": db_latency_ms,
|
||||
"active_connections": db_connections,
|
||||
},
|
||||
"app": {
|
||||
"events_last_hour": events_last_hour,
|
||||
"problems_open": problems_open,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Problem
|
||||
from app.services.notification_severity import severity_meets_minimum
|
||||
from app.services.notification_settings import TelegramConfig, get_effective_telegram_config
|
||||
from app.services.telegram_templates import format_event_telegram_html, format_problem_telegram_html
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramNotConfiguredError(Exception):
|
||||
"""Telegram disabled or missing token/chat_id."""
|
||||
|
||||
|
||||
class TelegramSendError(Exception):
|
||||
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def send_telegram_text(
|
||||
message: str,
|
||||
*,
|
||||
config: TelegramConfig | None = None,
|
||||
force: bool = False,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> None:
|
||||
cfg = config or get_effective_telegram_config()
|
||||
if not force and not cfg.active:
|
||||
return
|
||||
if not cfg.bot_token or not cfg.chat_id:
|
||||
if force:
|
||||
raise TelegramNotConfiguredError("Telegram: не задан bot token или chat_id")
|
||||
return
|
||||
|
||||
url = f"https://api.telegram.org/bot{cfg.bot_token}/sendMessage"
|
||||
payload: dict[str, str | bool] = {
|
||||
"chat_id": cfg.chat_id,
|
||||
"text": message,
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
if parse_mode:
|
||||
payload["parse_mode"] = parse_mode
|
||||
try:
|
||||
with httpx.Client(timeout=8.0) as client:
|
||||
response = client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text[:200] if exc.response is not None else str(exc)
|
||||
raise TelegramSendError(
|
||||
f"Telegram API HTTP {exc.response.status_code}: {detail}",
|
||||
status_code=exc.response.status_code,
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("telegram send failed")
|
||||
raise TelegramSendError(str(exc)) from exc
|
||||
|
||||
|
||||
def send_telegram_test_message(*, config: TelegramConfig | None = None) -> None:
|
||||
cfg = config or get_effective_telegram_config()
|
||||
if not cfg.enabled:
|
||||
raise TelegramNotConfiguredError("Telegram отключён (enabled=false)")
|
||||
if not cfg.configured:
|
||||
raise TelegramNotConfiguredError("Не задан bot token или chat_id")
|
||||
send_telegram_text(
|
||||
"✅ <b>SAC: тестовое сообщение</b>\nКанал Telegram для оповещений работает.\n"
|
||||
"📡 Оповещение: SAC (Security Alert Center)",
|
||||
config=cfg,
|
||||
force=True,
|
||||
)
|
||||
|
||||
|
||||
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
|
||||
cfg = get_effective_telegram_config(db)
|
||||
if not cfg.active:
|
||||
return
|
||||
if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity):
|
||||
return
|
||||
message = format_event_telegram_html(event)
|
||||
try:
|
||||
send_telegram_text(message, config=cfg)
|
||||
except TelegramSendError:
|
||||
logger.exception("notify_event telegram failed event_id=%s", event.event_id)
|
||||
|
||||
|
||||
def notify_problem(
|
||||
problem: Problem,
|
||||
event: Event | None = None,
|
||||
*,
|
||||
db: Session | None = None,
|
||||
apply_policy_gate: bool = True,
|
||||
) -> None:
|
||||
cfg = get_effective_telegram_config(db)
|
||||
if not cfg.active:
|
||||
return
|
||||
if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity):
|
||||
return
|
||||
message = format_problem_telegram_html(problem, event)
|
||||
try:
|
||||
send_telegram_text(message, config=cfg)
|
||||
except TelegramSendError:
|
||||
logger.exception("notify_problem telegram failed problem_id=%s", problem.id)
|
||||
@@ -0,0 +1,604 @@
|
||||
"""HTML Telegram message templates (RDP/SSH style, notif-30)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.models import Event, Host, Problem
|
||||
from app.services.daily_report_format import (
|
||||
append_notification_source_html,
|
||||
normalize_report_body,
|
||||
resolve_product_label,
|
||||
)
|
||||
|
||||
LOGON_TYPE_NAMES: dict[int, str] = {
|
||||
2: "Интерактивный (консоль)",
|
||||
3: "Сеть/RDP (Network)",
|
||||
4: "Пакетный (Batch)",
|
||||
5: "Сервис (Service)",
|
||||
7: "Разблокировка (Unlock)",
|
||||
8: "Сетевой с явными данными",
|
||||
9: "Новые учетные данные",
|
||||
10: "Удаленный интерактивный (RDP)",
|
||||
}
|
||||
|
||||
|
||||
# Telegram parse_mode=HTML: только теги из Bot API (без div/br/p и т.д.)
|
||||
_TELEGRAM_BR = re.compile(r"<br\s*/?>", re.I)
|
||||
_TELEGRAM_DROP_TAGS = re.compile(
|
||||
r"</?(?:div|p|ul|ol|li|hr|h[1-6]|table|tr|td|th|thead|tbody|body|html|head)\b[^>]*>",
|
||||
re.I,
|
||||
)
|
||||
_TELEGRAM_SPAN_OPEN = re.compile(r"<span\b(?![^>]*\btg-spoiler\b)[^>]*>", re.I)
|
||||
_TELEGRAM_SPAN_CLOSE = re.compile(r"</span>", re.I)
|
||||
|
||||
|
||||
def sanitize_telegram_html(text: str) -> str:
|
||||
"""Приводит HTML отчёта (UI/агент) к тегам, допустимым в Telegram."""
|
||||
out = _TELEGRAM_BR.sub("\n", text)
|
||||
out = _TELEGRAM_DROP_TAGS.sub("", out)
|
||||
out = _TELEGRAM_SPAN_OPEN.sub("", out)
|
||||
out = _TELEGRAM_SPAN_CLOSE.sub("", out)
|
||||
return out.strip()
|
||||
|
||||
|
||||
def html_escape(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return html.escape(str(value), quote=False)
|
||||
|
||||
|
||||
def _details_dict(event: Event | None) -> dict[str, Any]:
|
||||
if event is None or not isinstance(event.details, dict):
|
||||
return {}
|
||||
return event.details
|
||||
|
||||
|
||||
def _detail(details: dict[str, Any], *keys: str, default: str = "-") -> str:
|
||||
for key in keys:
|
||||
val = details.get(key)
|
||||
if val is not None and str(val).strip() not in ("", "-"):
|
||||
return str(val).strip()
|
||||
return default
|
||||
|
||||
|
||||
def logon_type_label(logon_type: Any) -> str:
|
||||
try:
|
||||
code = int(logon_type)
|
||||
except (TypeError, ValueError):
|
||||
return html_escape(logon_type) if logon_type not in (None, "") else "-"
|
||||
name = LOGON_TYPE_NAMES.get(code, f"Тип {code}")
|
||||
return f"{html_escape(name)} ({code})"
|
||||
|
||||
|
||||
def host_label(host: Host | None, *, fallback: str = "unknown") -> str:
|
||||
if host is None:
|
||||
return html_escape(fallback)
|
||||
if host.display_name and host.display_name.strip():
|
||||
return html_escape(host.display_name.strip())
|
||||
return html_escape(host.hostname)
|
||||
|
||||
|
||||
def _host_display_name_base(display_name: str | None) -> str:
|
||||
if not display_name or not display_name.strip():
|
||||
return ""
|
||||
return display_name.strip().split("(", 1)[0].strip()
|
||||
|
||||
|
||||
def _rdp_workstation_is_redundant(workstation: str, host: Host | None) -> bool:
|
||||
"""Скрыть Workstation Name, если пусто/«-» или совпадает с hostname/display_name сервера."""
|
||||
ws = (workstation or "").strip()
|
||||
if not ws or ws in ("-", "N/A", "?"):
|
||||
return True
|
||||
if host is None:
|
||||
return False
|
||||
ws_key = ws.casefold()
|
||||
if host.hostname and ws_key == host.hostname.strip().casefold():
|
||||
return True
|
||||
dn = (host.display_name or "").strip()
|
||||
if dn and ws_key == dn.casefold():
|
||||
return True
|
||||
base = _host_display_name_base(host.display_name)
|
||||
if base and ws_key == base.casefold():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def format_time(dt: datetime | None) -> str:
|
||||
if dt is None:
|
||||
return "-"
|
||||
try:
|
||||
return html_escape(dt.strftime("%d.%m.%Y %H:%M:%S"))
|
||||
except Exception:
|
||||
return html_escape(str(dt))
|
||||
|
||||
|
||||
def _line(emoji: str, label: str, value: str) -> str:
|
||||
return f"{emoji} {label}: {value}\n"
|
||||
|
||||
|
||||
def _event_content_author(event: Event) -> str:
|
||||
"""Кто сгенерировал содержимое события (ingest / отчёт)."""
|
||||
details = _details_dict(event)
|
||||
gb = details.get("generated_by")
|
||||
if isinstance(gb, str) and gb.strip().lower() in ("agent", "sac"):
|
||||
return gb.strip().lower()
|
||||
stats = details.get("stats")
|
||||
if isinstance(stats, dict) and stats.get("sac_generated"):
|
||||
return "sac"
|
||||
if event.type in ("report.daily.rdp", "report.daily.ssh") and details.get("report_body"):
|
||||
return "agent"
|
||||
return "agent"
|
||||
|
||||
|
||||
def _notification_delivered_by(event: Event) -> str:
|
||||
"""Кто доставил сообщение в Telegram (footer «📡 Оповещение»).
|
||||
|
||||
Шаблоны здесь используются только при отправке из SAC. Агент добавляет footer сам.
|
||||
"""
|
||||
details = _details_dict(event)
|
||||
via = details.get("telegram_via")
|
||||
if isinstance(via, str) and via.strip().lower() in ("agent", "sac"):
|
||||
return via.strip().lower()
|
||||
if _event_content_author(event) == "sac":
|
||||
return "sac"
|
||||
# ingest от агента, канал Telegram — SAC (exclusive / notify_lifecycle)
|
||||
return "sac"
|
||||
|
||||
|
||||
def _event_product_version(event: Event) -> tuple[str | None, str | None]:
|
||||
details = _details_dict(event)
|
||||
platform = None
|
||||
if event.type == "report.daily.ssh":
|
||||
platform = "ssh"
|
||||
elif event.type == "report.daily.rdp":
|
||||
platform = "windows"
|
||||
elif event.host is not None:
|
||||
if event.host.os_family == "linux":
|
||||
platform = "ssh"
|
||||
elif event.host.os_family == "windows":
|
||||
platform = "windows"
|
||||
product, version = resolve_product_label(event.host, platform)
|
||||
stats = details.get("stats")
|
||||
if isinstance(stats, dict):
|
||||
av = stats.get("agent_version") or stats.get("product_version")
|
||||
if av:
|
||||
version = str(av).strip()
|
||||
payload = event.payload if isinstance(event.payload, dict) else {}
|
||||
source = payload.get("source")
|
||||
if isinstance(source, dict):
|
||||
if source.get("product"):
|
||||
product = str(source["product"]).strip()
|
||||
if source.get("product_version"):
|
||||
version = str(source["product_version"]).strip()
|
||||
return product, version
|
||||
|
||||
|
||||
def _append_event_source(html_msg: str, event: Event) -> str:
|
||||
delivered = _notification_delivered_by(event)
|
||||
product, version = _event_product_version(event)
|
||||
return append_notification_source_html(
|
||||
html_msg,
|
||||
generated_by=delivered,
|
||||
product=product if delivered == "agent" else None,
|
||||
product_version=version if delivered == "agent" else None,
|
||||
)
|
||||
|
||||
|
||||
def format_rdp_login_html(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
is_success = event.type == "rdp.login.success"
|
||||
header = "✅ УСПЕШНЫЙ ВХОД" if is_success else "❌ НЕУДАЧНАЯ ПОПЫТКА"
|
||||
win_id = _detail(details, "event_id_windows", default="")
|
||||
if not win_id and event.type == "rdp.login.failed":
|
||||
win_id = "4625"
|
||||
elif not win_id and is_success:
|
||||
win_id = "4624"
|
||||
|
||||
user = html_escape(_detail(details, "user", "username"))
|
||||
ip = html_escape(_detail(details, "ip_address", "source_ip", "ip"))
|
||||
workstation_raw = _detail(details, "workstation_name", "computer_name")
|
||||
workstation = html_escape(workstation_raw)
|
||||
process = html_escape(_detail(details, "process_name", "process", default=""))
|
||||
logon = logon_type_label(details.get("logon_type"))
|
||||
|
||||
msg = f"<b>{header}</b>\n"
|
||||
msg += _line("👤", "Пользователь", user)
|
||||
msg += _line("🏢", "Сервер", host_label(event.host))
|
||||
if not _rdp_workstation_is_redundant(workstation_raw, event.host):
|
||||
msg += _line("🖥️", "Рабочая станция", workstation)
|
||||
msg += _line("🌐", "IP адрес", ip)
|
||||
if process and process != "-":
|
||||
msg += _line("⚙️", "Процесс", process)
|
||||
msg += _line("🔑", "Тип входа", logon)
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
if win_id:
|
||||
msg += f"🔢 Event ID: {html_escape(win_id)}"
|
||||
return msg.rstrip()
|
||||
|
||||
|
||||
def format_ssh_auth_html(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
is_success = event.type == "ssh.login.success"
|
||||
header = "✅ SSH: успешный вход" if is_success else "❌ SSH: неудачная попытка"
|
||||
|
||||
user = html_escape(_detail(details, "user", "username"))
|
||||
ip = html_escape(_detail(details, "source_ip", "ip_address", "ip"))
|
||||
port = html_escape(_detail(details, "port", default=""))
|
||||
attempt = _detail(details, "attempt_number", default="")
|
||||
max_attempts = _detail(details, "max_attempts", default="")
|
||||
|
||||
msg = f"<b>{header}</b>\n"
|
||||
msg += _line("👤", "Пользователь", user)
|
||||
msg += _line("🏢", "Хост", host_label(event.host))
|
||||
msg += _line("🌐", "IP", ip)
|
||||
if port and port != "-":
|
||||
msg += _line("🔌", "Порт", port)
|
||||
if attempt != "-" and max_attempts != "-":
|
||||
msg += _line("🔢", "Попытка", f"{html_escape(attempt)} / {html_escape(max_attempts)}")
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
msg += f"📋 {html_escape(event.type)}"
|
||||
return msg.rstrip()
|
||||
|
||||
|
||||
def format_sudo_html(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
risk = _detail(details, "risk_level", default="")
|
||||
header = "⚠️ SUDO"
|
||||
if risk and risk.lower() == "critical":
|
||||
header = "🔥 SUDO (critical)"
|
||||
|
||||
msg = f"<b>{header}</b>\n"
|
||||
msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username")))
|
||||
msg += _line("🏢", "Хост", host_label(event.host))
|
||||
msg += _line("▶️", "Команда", html_escape(_detail(details, "command", default=event.summary[:200])))
|
||||
run_as = _detail(details, "run_as", default="")
|
||||
if run_as != "-":
|
||||
msg += _line("👥", "От имени", html_escape(run_as))
|
||||
pwd = _detail(details, "pwd", default="")
|
||||
if pwd != "-":
|
||||
msg += _line("📁", "Каталог", html_escape(pwd))
|
||||
if risk != "-":
|
||||
msg += _line("⚡", "Risk", html_escape(risk))
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
return msg.rstrip()
|
||||
|
||||
|
||||
def format_rdp_shadow_html(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
action = _detail(details, "shadow_action", default="")
|
||||
if action == "control_stopped" or event.type.endswith(".stopped"):
|
||||
header = "🎭 RDS SHADOW CONTROL — остановлено"
|
||||
elif action == "control_permission" or event.type.endswith(".permission"):
|
||||
header = "🎭 RDS SHADOW CONTROL — разрешение"
|
||||
else:
|
||||
header = "🎭 RDS SHADOW CONTROL — начато"
|
||||
|
||||
msg = f"<b>{header}</b>\n"
|
||||
msg += _line("🏢", "Сервер", host_label(event.host))
|
||||
msg += _line("👤", "Администратор", html_escape(_detail(details, "shadower_user", "user")))
|
||||
msg += _line("🎯", "Сессия пользователя", html_escape(_detail(details, "target_user", default="-")))
|
||||
sid = _detail(details, "session_id", "target_session_id", default="")
|
||||
if sid != "-":
|
||||
msg += _line("🔢", "Session ID", html_escape(sid))
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
win_id = _detail(details, "event_id_windows", default="")
|
||||
if win_id != "-":
|
||||
msg += f"🔢 Event ID: {html_escape(win_id)} (RemoteConnectionManager)"
|
||||
return msg.rstrip()
|
||||
|
||||
|
||||
def format_winrm_session_html(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
msg = "<b>⚠️ WinRM / Enter-PSSession — удалённая shell</b>\n"
|
||||
msg += _line("🏢", "Сервер", host_label(event.host))
|
||||
msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username")))
|
||||
ip = _detail(details, "source_ip", "ip_address", "ip")
|
||||
if ip != "-":
|
||||
msg += _line("🌐", "IP источника", html_escape(ip))
|
||||
uri = _detail(details, "resource_uri", default="")
|
||||
if uri != "-":
|
||||
msg += _line("🔗", "ResourceUri", html_escape(uri))
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
win_id = _detail(details, "event_id_windows", default="")
|
||||
if win_id != "-":
|
||||
msg += f"🔢 Event ID: {html_escape(win_id)} (WinRM Operational)"
|
||||
return msg.rstrip()
|
||||
|
||||
|
||||
def format_smb_admin_share_html(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
msg = "<b>📁 Доступ к админ-шару (5140)</b>\n"
|
||||
msg += _line("🏢", "Сервер", host_label(event.host))
|
||||
msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username")))
|
||||
ip = _detail(details, "source_ip", "ip_address", "ip")
|
||||
if ip != "-":
|
||||
msg += _line("🌐", "IP клиента", html_escape(ip))
|
||||
share = _detail(details, "share_name", default="")
|
||||
if share != "-":
|
||||
msg += _line("📂", "Share", html_escape(share))
|
||||
path = _detail(details, "share_path", default="")
|
||||
if path != "-":
|
||||
msg += _line("📍", "Путь", html_escape(path))
|
||||
target = _detail(details, "relative_target", default="")
|
||||
if target != "-":
|
||||
msg += _line("📄", "Объект", html_escape(target))
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
win_id = _detail(details, "event_id_windows", default="")
|
||||
if win_id != "-":
|
||||
msg += f"🔢 Event ID: {html_escape(win_id)} (File Share)"
|
||||
return msg.rstrip()
|
||||
|
||||
|
||||
def _format_inventory_change_line(change: dict[str, Any]) -> str:
|
||||
field = html_escape(str(change.get("field") or "?"))
|
||||
old = change.get("old")
|
||||
new = change.get("new")
|
||||
return f"• <b>{field}</b>: {html_escape(old)} → {html_escape(new)}"
|
||||
|
||||
|
||||
def format_agent_inventory_html(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
changes = details.get("hardware_changes")
|
||||
if isinstance(changes, list) and changes:
|
||||
msg = "<b>⚠️ Изменилось железо хоста</b>\n"
|
||||
msg += _line("🏢", "Хост", host_label(event.host))
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
msg += "\n"
|
||||
for item in changes:
|
||||
if isinstance(item, dict):
|
||||
msg += _format_inventory_change_line(item) + "\n"
|
||||
if event.summary:
|
||||
msg += f"\n{html_escape(event.summary)}"
|
||||
return msg.rstrip()
|
||||
|
||||
inv = details.get("inventory")
|
||||
if not isinstance(inv, dict):
|
||||
inv = {}
|
||||
msg = "<b>🖥️ Инвентаризация хоста</b>\n"
|
||||
msg += _line("🏢", "Хост", host_label(event.host))
|
||||
windows = inv.get("windows") if isinstance(inv.get("windows"), dict) else {}
|
||||
os_name = windows.get("product_name") or event.host.os_version if event.host else None
|
||||
if os_name:
|
||||
msg += _line("💿", "ОС", html_escape(os_name))
|
||||
mem = inv.get("memory_gb")
|
||||
if mem is not None:
|
||||
msg += _line("🧠", "RAM", f"{html_escape(mem)} GB")
|
||||
procs = inv.get("processor")
|
||||
if isinstance(procs, list) and procs:
|
||||
names = [str(p.get("name") or "").strip() for p in procs if isinstance(p, dict)]
|
||||
names = [n for n in names if n]
|
||||
if names:
|
||||
msg += _line("⚙️", "CPU", html_escape("; ".join(names)))
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
return msg.rstrip()
|
||||
|
||||
|
||||
def format_generic_event_html(event: Event) -> str:
|
||||
sev = event.severity.upper()
|
||||
msg = f"<b>🚨 SAC: {html_escape(event.title)}</b>\n"
|
||||
msg += _line("🏢", "Хост", host_label(event.host))
|
||||
msg += _line("📋", "Тип", html_escape(event.type))
|
||||
msg += _line("⚡", "Severity", html_escape(sev))
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
if event.summary:
|
||||
msg += f"\n{html_escape(event.summary)}"
|
||||
return msg.rstrip()
|
||||
|
||||
|
||||
_LIFECYCLE_HEADERS: dict[str, str] = {
|
||||
"started": "✅ Агент запущен",
|
||||
"stopped": "⚠️ Агент остановлен",
|
||||
"settings_reloaded": "🔄 Настройки агента перечитаны",
|
||||
}
|
||||
|
||||
_LIFECYCLE_TRIGGER_LABELS: dict[str, str] = {
|
||||
"boot": "загрузка ОС / задача планировщика",
|
||||
"deploy_recycle": "обновление скрипта (recycle)",
|
||||
"settings_reload": "graceful restart (settings)",
|
||||
"manual_recycle": "ручной recycle",
|
||||
"shutdown": "остановка процесса",
|
||||
}
|
||||
|
||||
|
||||
def _format_lifecycle_body_html(body: str) -> str:
|
||||
text = body.replace("\r\n", "\n").strip()
|
||||
if not text:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for i, line in enumerate(text.split("\n")):
|
||||
esc = html.escape(line)
|
||||
if i == 0 and line.strip():
|
||||
parts.append(f"<b>{esc.strip()}</b>")
|
||||
else:
|
||||
parts.append(esc)
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
def format_lifecycle_html(event: Event) -> str:
|
||||
details = _details_dict(event)
|
||||
notification_body = details.get("notification_body")
|
||||
if isinstance(notification_body, str) and notification_body.strip():
|
||||
return _format_lifecycle_body_html(notification_body)
|
||||
|
||||
lifecycle = _detail(details, "lifecycle", default="started").strip().lower()
|
||||
trigger = _detail(details, "trigger", default="").strip().lower()
|
||||
header = _LIFECYCLE_HEADERS.get(lifecycle, f"📋 Lifecycle: {html_escape(lifecycle)}")
|
||||
|
||||
msg = f"<b>{header}</b>\n"
|
||||
msg += _line("🏢", "Хост", host_label(event.host))
|
||||
product = ""
|
||||
version = ""
|
||||
payload = event.payload if isinstance(event.payload, dict) else {}
|
||||
source = payload.get("source")
|
||||
if isinstance(source, dict):
|
||||
product = str(source.get("product") or "").strip()
|
||||
version = str(source.get("product_version") or "").strip()
|
||||
if product or version:
|
||||
label = " ".join(x for x in (product, version) if x).strip()
|
||||
if label:
|
||||
msg += _line("📦", "Версия", html_escape(label))
|
||||
if trigger and trigger != "-":
|
||||
trigger_human = _LIFECYCLE_TRIGGER_LABELS.get(trigger, trigger)
|
||||
msg += _line("🔁", "Причина", html_escape(trigger_human))
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
if event.summary:
|
||||
msg += f"\n{html_escape(event.summary)}"
|
||||
return msg.rstrip()
|
||||
|
||||
|
||||
def _format_event_body_html(event: Event) -> str:
|
||||
if event.type == "agent.lifecycle":
|
||||
return format_lifecycle_html(event)
|
||||
if event.type in ("report.daily.ssh", "report.daily.rdp"):
|
||||
details = _details_dict(event)
|
||||
platform = "windows" if event.type == "report.daily.rdp" else "ssh"
|
||||
body = _detail(details, "report_body", default="")
|
||||
if body and body != "-":
|
||||
body = normalize_report_body(body, event.host, platform)
|
||||
parts: list[str] = []
|
||||
for i, line in enumerate(body.split("\n")):
|
||||
esc = html.escape(line)
|
||||
if i == 0 and "ЕЖЕДНЕВНЫЙ ОТЧЕТ" in line:
|
||||
parts.append(f"<b>{esc.strip()}</b>")
|
||||
else:
|
||||
parts.append(esc)
|
||||
return "\n".join(parts)
|
||||
report_html = details.get("report_html")
|
||||
if isinstance(report_html, str) and report_html.strip():
|
||||
text = sanitize_telegram_html(report_html.strip())
|
||||
return re.sub(r"\n{3,}", "\n\n", text)
|
||||
body = _detail(details, "report_body", default=event.summary)
|
||||
if body != "-":
|
||||
header = (
|
||||
"📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS"
|
||||
if event.type == "report.daily.rdp"
|
||||
else "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА"
|
||||
)
|
||||
msg = f"<b>{html.escape(header)}</b>\n"
|
||||
msg += _line("🏢", "Сервер", host_label(event.host))
|
||||
msg += f"\n{html.escape(body)}"
|
||||
return msg
|
||||
return format_generic_event_html(event)
|
||||
if event.type in ("rdp.login.success", "rdp.login.failed"):
|
||||
return format_rdp_login_html(event)
|
||||
if event.type in ("ssh.login.success", "ssh.login.failed"):
|
||||
return format_ssh_auth_html(event)
|
||||
if event.type == "privilege.sudo.command":
|
||||
return format_sudo_html(event)
|
||||
if event.type.startswith("rdg."):
|
||||
return _format_rdg_html(event)
|
||||
if event.type.startswith("rdp.shadow."):
|
||||
return format_rdp_shadow_html(event)
|
||||
if event.type.startswith("winrm."):
|
||||
return format_winrm_session_html(event)
|
||||
if event.type.startswith("smb."):
|
||||
return format_smb_admin_share_html(event)
|
||||
if event.type == "agent.inventory":
|
||||
return format_agent_inventory_html(event)
|
||||
return format_generic_event_html(event)
|
||||
|
||||
|
||||
def format_event_telegram_html(event: Event) -> str:
|
||||
return _append_event_source(_format_event_body_html(event), event)
|
||||
|
||||
|
||||
_TELEGRAM_INLINE_TAGS = re.compile(r"</?(?:b|i|u|s|code|pre|tg-spoiler)(\s[^>]*)?>", re.I)
|
||||
_TELEGRAM_ANCHOR_OPEN = re.compile(r"<a\b[^>]*>", re.I)
|
||||
_TELEGRAM_ANCHOR_CLOSE = re.compile(r"</a>", re.I)
|
||||
|
||||
_MOBILE_PUSH_BODY_MAX = 3500
|
||||
|
||||
|
||||
def telegram_html_to_plaintext(text: str) -> str:
|
||||
"""Plain text for Seaca push from the same HTML templates as Telegram."""
|
||||
out = sanitize_telegram_html(text)
|
||||
out = _TELEGRAM_INLINE_TAGS.sub("", out)
|
||||
out = _TELEGRAM_ANCHOR_OPEN.sub("", out)
|
||||
out = _TELEGRAM_ANCHOR_CLOSE.sub("", out)
|
||||
out = html.unescape(out)
|
||||
out = re.sub(r"\n{3,}", "\n\n", out)
|
||||
return out.strip()
|
||||
|
||||
|
||||
def format_event_mobile_push(event: Event) -> tuple[str, str]:
|
||||
"""Title + body for FCM data payload (mirrors Telegram wording)."""
|
||||
plain = telegram_html_to_plaintext(format_event_telegram_html(event))
|
||||
lines = [line.strip() for line in plain.split("\n") if line.strip()]
|
||||
if lines:
|
||||
title = lines[0]
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
if not body:
|
||||
body = (event.summary or "").strip()
|
||||
else:
|
||||
title = f"[{event.severity}] {event.title}"
|
||||
body = (event.summary or "").strip()
|
||||
if len(title) > 120:
|
||||
title = title[:117] + "…"
|
||||
if len(body) > _MOBILE_PUSH_BODY_MAX:
|
||||
body = body[: _MOBILE_PUSH_BODY_MAX - 1] + "…"
|
||||
return title, body
|
||||
|
||||
|
||||
def _format_rdg_html(event: Event) -> str:
|
||||
from app.services.rdg_display import build_rdg_display
|
||||
|
||||
details = _details_dict(event)
|
||||
display = build_rdg_display(event)
|
||||
if event.type.endswith(".success"):
|
||||
header = "✅ RDS через RD Gateway"
|
||||
elif event.type.endswith(".disconnected"):
|
||||
header = "ℹ️ RDS отключение (RD Gateway)"
|
||||
else:
|
||||
header = "❌ RDS ошибка (RD Gateway)"
|
||||
msg = f"<b>{header}</b>\n"
|
||||
if display is not None and display.access_path:
|
||||
msg += _line("🔀", "Путь", html_escape(display.access_path))
|
||||
msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username")))
|
||||
msg += _line("🚪", "Шлюз RDG", host_label(event.host))
|
||||
msg += _line("🌐", "Внешний IP", html_escape(_detail(details, "external_ip", "ip_address")))
|
||||
msg += _line("🖥️", "Рабочий ПК", html_escape(_detail(details, "internal_ip", default="-")))
|
||||
err = _detail(details, "gateway_error_code", "error_code", default="")
|
||||
if err != "-":
|
||||
msg += _line("⚠️", "Код ошибки", html_escape(err))
|
||||
from app.services.session_duration import extract_session_duration_sec, format_session_duration
|
||||
|
||||
dur_sec = extract_session_duration_sec(details if isinstance(details, dict) else None)
|
||||
if dur_sec is not None and dur_sec > 0:
|
||||
msg += _line("⏱️", "Длительность", html_escape(format_session_duration(dur_sec)))
|
||||
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||
win_id = _detail(details, "event_id_windows", default="")
|
||||
if win_id != "-":
|
||||
msg += f"\n🔢 Event ID: {html_escape(win_id)}"
|
||||
return msg.rstrip()
|
||||
|
||||
|
||||
def format_problem_telegram_html(problem: Problem, event: Event | None = None) -> str:
|
||||
msg = "<b>🔥 SAC Problem</b>\n"
|
||||
msg += _line("🏢", "Хост", host_label(problem.host))
|
||||
msg += _line("📏", "Severity", html_escape(problem.severity.upper()))
|
||||
if problem.rule_id:
|
||||
msg += _line("📐", "Правило", html_escape(problem.rule_id))
|
||||
msg += _line("🔢", "Событий", html_escape(problem.event_count))
|
||||
msg += _line("🕐", "Last seen", format_time(problem.last_seen_at))
|
||||
msg += f"\n<b>{html_escape(problem.title)}</b>\n{html_escape(problem.summary)}"
|
||||
if event is not None:
|
||||
msg += f"\n\n<i>Триггер:</i> {html_escape(event.type)} ({html_escape(event.severity)})"
|
||||
if event.type in (
|
||||
"rdp.login.success",
|
||||
"rdp.login.failed",
|
||||
"ssh.login.success",
|
||||
"ssh.login.failed",
|
||||
"rdp.shadow.control.started",
|
||||
"rdp.shadow.control.stopped",
|
||||
"rdp.shadow.control.permission",
|
||||
"winrm.session.started",
|
||||
"smb.admin_share.access",
|
||||
):
|
||||
msg += "\n" + _format_event_body_html(event)
|
||||
elif problem.rule_id == "rule:host_silence":
|
||||
msg += "\n\n<i>Триггер:</i> периодическая проверка SAC (host_silence scan)"
|
||||
return append_notification_source_html(msg, generated_by="sac")
|
||||
@@ -0,0 +1,30 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UiSettingsConfig:
|
||||
show_sidebar_system_stats: bool
|
||||
source: str = "db"
|
||||
|
||||
|
||||
def get_effective_ui_settings(db: Session) -> UiSettingsConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
return UiSettingsConfig(show_sidebar_system_stats=True, source="default")
|
||||
return UiSettingsConfig(show_sidebar_system_stats=bool(row.show_sidebar_system_stats), source="db")
|
||||
|
||||
|
||||
def upsert_ui_settings(db: Session, *, show_sidebar_system_stats: bool) -> UiSettingsConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
if row is None:
|
||||
row = UiSettings(id=UI_SETTINGS_ROW_ID, show_sidebar_system_stats=show_sidebar_system_stats)
|
||||
db.add(row)
|
||||
else:
|
||||
row.show_sidebar_system_stats = show_sidebar_system_stats
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return get_effective_ui_settings(db)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""SAC UI users: password hashing, authentication, bootstrap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, USER_ROLES, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return _pwd_context.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _pwd_context.verify(plain, password_hash)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||
normalized = username.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return db.scalar(
|
||||
select(User).where(
|
||||
func.lower(User.username) == normalized.lower(),
|
||||
User.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str) -> User | None:
|
||||
user = get_user_by_username(db, username)
|
||||
if user is None:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def create_user(
|
||||
db: Session,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
role: str = USER_ROLE_MONITOR,
|
||||
) -> User:
|
||||
normalized = username.strip()
|
||||
if not normalized:
|
||||
raise ValueError("username is required")
|
||||
if role not in USER_ROLES:
|
||||
raise ValueError(f"invalid role: {role}")
|
||||
if len(password) < 8:
|
||||
raise ValueError("password must be at least 8 characters")
|
||||
existing = db.scalar(select(User).where(func.lower(User.username) == normalized.lower()))
|
||||
if existing is not None:
|
||||
raise ValueError("username already exists")
|
||||
user = User(
|
||||
username=normalized,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
return user
|
||||
|
||||
|
||||
def bootstrap_admin_user(db: Session) -> None:
|
||||
"""Create first admin from SAC_ADMIN_* when sac_users table is empty."""
|
||||
try:
|
||||
count = db.scalar(select(func.count()).select_from(User)) or 0
|
||||
except Exception as exc:
|
||||
logger.warning("SAC users bootstrap skipped (run alembic upgrade head): %s", exc)
|
||||
db.rollback()
|
||||
return
|
||||
if count > 0:
|
||||
return
|
||||
settings = get_settings()
|
||||
if not settings.sac_admin_password:
|
||||
logger.warning("SAC users table empty and SAC_ADMIN_PASSWORD not set — UI login disabled until a user is created")
|
||||
return
|
||||
username = (settings.sac_admin_username or "admin").strip() or "admin"
|
||||
create_user(db, username=username, password=settings.sac_admin_password, role=USER_ROLE_ADMIN)
|
||||
db.commit()
|
||||
logger.info("SAC bootstrap: created admin user %r from SAC_ADMIN_* env", username)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Generic JSON webhook notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Event, Problem
|
||||
from app.services.notification_severity import severity_meets_minimum
|
||||
from app.services.notification_settings import WebhookConfig, get_effective_webhook_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookNotConfiguredError(Exception):
|
||||
"""Webhook disabled or missing URL."""
|
||||
|
||||
|
||||
class WebhookSendError(Exception):
|
||||
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _host_payload(entity: Event | Problem) -> dict[str, Any]:
|
||||
host = entity.host
|
||||
if host is None:
|
||||
return {"hostname": "unknown", "display_name": None}
|
||||
return {"hostname": host.hostname, "display_name": host.display_name}
|
||||
|
||||
|
||||
def build_event_payload(event: Event) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": "event",
|
||||
"event_id": event.event_id,
|
||||
"type": event.type,
|
||||
"severity": event.severity,
|
||||
"category": event.category,
|
||||
"title": event.title,
|
||||
"summary": event.summary,
|
||||
"host": _host_payload(event),
|
||||
"occurred_at": event.occurred_at.isoformat() if event.occurred_at else None,
|
||||
}
|
||||
|
||||
|
||||
def build_problem_payload(problem: Problem, event: Event | None = None) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"kind": "problem",
|
||||
"problem_id": problem.id,
|
||||
"rule_id": problem.rule_id,
|
||||
"severity": problem.severity,
|
||||
"status": problem.status,
|
||||
"title": problem.title,
|
||||
"summary": problem.summary,
|
||||
"host": _host_payload(problem),
|
||||
"opened_at": problem.opened_at.isoformat() if problem.opened_at else None,
|
||||
}
|
||||
if event is not None:
|
||||
payload["trigger_event"] = {
|
||||
"event_id": event.event_id,
|
||||
"type": event.type,
|
||||
"severity": event.severity,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def send_webhook_payload(payload: dict[str, Any], *, config: WebhookConfig | None = None, force: bool = False) -> None:
|
||||
cfg = config or get_effective_webhook_config()
|
||||
if not force and not cfg.active:
|
||||
return
|
||||
if not cfg.url:
|
||||
if force:
|
||||
raise WebhookNotConfiguredError("Webhook: не задан URL")
|
||||
return
|
||||
|
||||
headers = {"Content-Type": "application/json", "User-Agent": "SecurityAlertCenter/1.0"}
|
||||
if cfg.secret_header and cfg.secret:
|
||||
headers[cfg.secret_header] = cfg.secret
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
response = client.post(cfg.url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text[:200] if exc.response is not None else str(exc)
|
||||
raise WebhookSendError(
|
||||
f"Webhook HTTP {exc.response.status_code}: {detail}",
|
||||
status_code=exc.response.status_code,
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("webhook send failed")
|
||||
raise WebhookSendError(str(exc)) from exc
|
||||
|
||||
|
||||
def send_webhook_test_message(*, config: WebhookConfig | None = None) -> None:
|
||||
cfg = config or get_effective_webhook_config()
|
||||
if not cfg.enabled:
|
||||
raise WebhookNotConfiguredError("Webhook отключён (enabled=false)")
|
||||
if not cfg.configured:
|
||||
raise WebhookNotConfiguredError("Не задан webhook URL")
|
||||
send_webhook_payload(
|
||||
{
|
||||
"kind": "test",
|
||||
"message": "SAC webhook test",
|
||||
"source": "security-alert-center",
|
||||
},
|
||||
config=cfg,
|
||||
force=True,
|
||||
)
|
||||
|
||||
|
||||
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
|
||||
cfg = get_effective_webhook_config(db)
|
||||
if not cfg.active:
|
||||
return
|
||||
if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity):
|
||||
return
|
||||
try:
|
||||
send_webhook_payload(build_event_payload(event), config=cfg)
|
||||
except WebhookSendError:
|
||||
logger.exception("notify_event webhook failed event_id=%s", event.event_id)
|
||||
|
||||
|
||||
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
|
||||
cfg = get_effective_webhook_config(db)
|
||||
if not cfg.active:
|
||||
return
|
||||
if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity):
|
||||
return
|
||||
try:
|
||||
send_webhook_payload(build_problem_payload(problem, event), config=cfg)
|
||||
except WebhookSendError:
|
||||
logger.exception("notify_problem webhook failed problem_id=%s", problem.id)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Effective Windows domain admin credentials (host → DB → env)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.host import Host
|
||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WinAdminConfig:
|
||||
user: str
|
||||
password: str
|
||||
source: str # host | env | db
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.user.strip() and self.password.strip())
|
||||
|
||||
|
||||
def normalize_win_admin_user(user: str) -> str:
|
||||
"""DOMAIN\\user — один обратный слэш; убрать лишнее экранирование из env/UI."""
|
||||
text = user.strip()
|
||||
if not text:
|
||||
return ""
|
||||
text = text.replace("\\\\", "\\")
|
||||
if "\\" not in text and "@" not in text and "/" not in text:
|
||||
# NETBIOS\user без слэша — не трогаем (оператор допишет в UI)
|
||||
return text
|
||||
return text
|
||||
|
||||
|
||||
def _win_admin_from_env() -> WinAdminConfig:
|
||||
settings = get_settings()
|
||||
return WinAdminConfig(
|
||||
user=normalize_win_admin_user(settings.sac_win_admin_user),
|
||||
password=settings.sac_win_admin_password,
|
||||
source="env",
|
||||
)
|
||||
|
||||
|
||||
def get_effective_win_admin_config(db: Session | None = None) -> WinAdminConfig:
|
||||
if db is None:
|
||||
from app.database import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
return get_effective_win_admin_config(session)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_cfg = _win_admin_from_env()
|
||||
if row is None:
|
||||
return env_cfg
|
||||
|
||||
user = normalize_win_admin_user((row.win_admin_user or "").strip() or env_cfg.user)
|
||||
password = (row.win_admin_password or "") or env_cfg.password
|
||||
has_db_values = bool((row.win_admin_user or "").strip() or (row.win_admin_password or "").strip())
|
||||
return WinAdminConfig(
|
||||
user=user,
|
||||
password=password,
|
||||
source="db" if has_db_values else env_cfg.source,
|
||||
)
|
||||
|
||||
|
||||
def upsert_win_admin_settings(
|
||||
db: Session,
|
||||
*,
|
||||
user: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> WinAdminConfig:
|
||||
row = db.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||
env_cfg = _win_admin_from_env()
|
||||
if row is None:
|
||||
row = UiSettings(
|
||||
id=UI_SETTINGS_ROW_ID,
|
||||
show_sidebar_system_stats=True,
|
||||
win_admin_user=env_cfg.user or None,
|
||||
win_admin_password=env_cfg.password or None,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
if user is not None and user.strip():
|
||||
row.win_admin_user = normalize_win_admin_user(user)
|
||||
if password is not None and password.strip():
|
||||
row.win_admin_password = password
|
||||
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
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
|
||||
@@ -0,0 +1,692 @@
|
||||
"""WinRM connectivity test for Windows hosts using domain admin credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Host
|
||||
from app.services.agent_git_release import _cache_dir, clone_or_update_repo
|
||||
from app.services.rdp_bundle_delivery import build_rdp_bundle_zip, register_rdp_bundle_zip
|
||||
|
||||
_CLIXML_PROGRESS_NOISE = frozenset(
|
||||
{
|
||||
"Preparing modules for first use.",
|
||||
"Preparing modules fo",
|
||||
}
|
||||
)
|
||||
_CLIXML_MARKER = "#< CLIXML"
|
||||
_CYRILLIC_RE = re.compile(r"[\u0400-\u04FF]")
|
||||
RDP_REMOTE_STAGING_DIRNAME = "sac-rdp-staging"
|
||||
RDP_LEGACY_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging"
|
||||
RDP_BUNDLE_FILES = (
|
||||
"Login_Monitor.ps1",
|
||||
"Sac-Client.ps1",
|
||||
"RdpMonitor-TaskQuery.ps1",
|
||||
"version.txt",
|
||||
"Deploy-LoginMonitor.ps1",
|
||||
"Restart-RdpLoginMonitor.ps1",
|
||||
"login_monitor.settings.example.ps1",
|
||||
)
|
||||
RDP_BUNDLE_REQUIRED = frozenset(
|
||||
{
|
||||
"Login_Monitor.ps1",
|
||||
"Sac-Client.ps1",
|
||||
"version.txt",
|
||||
"Deploy-LoginMonitor.ps1",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class WinAdminNotConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HostNotWindowsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HostTargetMissingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WinRmTestResult:
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
hostname: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WinRmCmdResult:
|
||||
ok: bool
|
||||
message: str
|
||||
target: str
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
exit_code: int | None = None
|
||||
|
||||
|
||||
def _winrm_session(
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
*,
|
||||
timeout_sec: int = 15,
|
||||
):
|
||||
from app.services.win_admin_settings import normalize_win_admin_user
|
||||
|
||||
user = normalize_win_admin_user(user.strip())
|
||||
target = target.strip()
|
||||
if not target or not user or not password:
|
||||
raise WinAdminNotConfiguredError("Windows admin credentials or target missing")
|
||||
|
||||
try:
|
||||
import winrm
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("pywinrm is not installed on SAC server") from exc
|
||||
|
||||
operation_timeout_sec = max(5, timeout_sec)
|
||||
read_timeout_sec = operation_timeout_sec + 15
|
||||
settings = get_settings()
|
||||
scheme = "https" if settings.sac_winrm_use_https else "http"
|
||||
port = 5986 if settings.sac_winrm_use_https else 5985
|
||||
endpoint = f"{scheme}://{target}:{port}/wsman"
|
||||
cert_validation = (settings.sac_winrm_server_cert_validation or "validate").strip().lower()
|
||||
if cert_validation not in {"validate", "ignore"}:
|
||||
cert_validation = "validate"
|
||||
session = winrm.Session(
|
||||
endpoint,
|
||||
auth=(user, password),
|
||||
transport="ntlm",
|
||||
read_timeout_sec=read_timeout_sec,
|
||||
operation_timeout_sec=operation_timeout_sec,
|
||||
server_cert_validation=cert_validation,
|
||||
)
|
||||
return session, winrm
|
||||
|
||||
|
||||
def _clixml_to_plain(text: str) -> str:
|
||||
"""Turn WinRM/PowerShell CLIXML blobs into readable text; drop progress noise."""
|
||||
raw = text or ""
|
||||
if _CLIXML_MARKER not in raw:
|
||||
return raw.strip()
|
||||
|
||||
messages = re.findall(r'<S N="Message">([^<]*)</S>', raw, flags=re.IGNORECASE)
|
||||
messages = [
|
||||
msg.strip()
|
||||
for msg in messages
|
||||
if msg.strip() and msg.strip() not in _CLIXML_PROGRESS_NOISE
|
||||
]
|
||||
to_strings = re.findall(r"<ToString>([^<]*)</ToString>", raw, flags=re.IGNORECASE)
|
||||
to_strings = [
|
||||
text.strip()
|
||||
for text in to_strings
|
||||
if text.strip() and "Preparing modules" not in text
|
||||
]
|
||||
parts = [*messages, *to_strings]
|
||||
if parts:
|
||||
return "\n".join(parts)
|
||||
|
||||
without = re.sub(r"#< CLIXML.*", "", raw, flags=re.DOTALL).strip()
|
||||
return without
|
||||
|
||||
|
||||
def _decode_text_score(text: str) -> int:
|
||||
cyrillic = len(_CYRILLIC_RE.findall(text))
|
||||
suspicious = len(re.findall(r"\?{3,}", text))
|
||||
return cyrillic * 5 - text.count("\ufffd") * 20 - suspicious * 15 - max(0, text.count("?") - cyrillic) * 2
|
||||
|
||||
|
||||
def _decode_winrm_bytes(data: bytes) -> str:
|
||||
if not data:
|
||||
return ""
|
||||
for encoding in ("utf-8-sig", "utf-8"):
|
||||
try:
|
||||
text = data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
break
|
||||
score = _decode_text_score(text)
|
||||
if text.isascii() or score > 0:
|
||||
return text
|
||||
best_score = -10_000
|
||||
best_text = ""
|
||||
for encoding in ("cp866", "cp1251"):
|
||||
try:
|
||||
text = data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
score = _decode_text_score(text)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_text = text
|
||||
if best_text:
|
||||
return best_text
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _winrm_failure_detail(stdout: str, stderr: str, exit_code: int) -> str:
|
||||
stdout_plain = _clixml_to_plain(stdout)
|
||||
stderr_plain = _clixml_to_plain(stderr)
|
||||
|
||||
for text in (stderr_plain, stdout_plain):
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("ERROR:"):
|
||||
return stripped[6:].strip() or stripped
|
||||
|
||||
for candidate in (stderr_plain, stdout_plain):
|
||||
if candidate and _CLIXML_MARKER not in candidate:
|
||||
lines = [line.strip() for line in candidate.splitlines() if line.strip()]
|
||||
if lines:
|
||||
return lines[-1][:2000]
|
||||
|
||||
if stdout_plain:
|
||||
return stdout_plain[:2000]
|
||||
if stderr_plain:
|
||||
return stderr_plain[:2000]
|
||||
return (
|
||||
f"PowerShell exit code {exit_code} "
|
||||
"(CLIXML без текста ошибки — проверьте Deploy-LoginMonitor.ps1 на ПК)"
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_winrm_streams(stdout: str, stderr: str) -> tuple[str, str]:
|
||||
return _clixml_to_plain(stdout), _clixml_to_plain(stderr)
|
||||
|
||||
|
||||
def _finalize_winrm_run(
|
||||
*,
|
||||
target: str,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
exit_code: int,
|
||||
) -> WinRmCmdResult:
|
||||
ok = exit_code == 0
|
||||
if ok:
|
||||
message = f"WinRM OK ({target}), exit 0"
|
||||
if stdout.strip():
|
||||
message = f"{message}\n{stdout.strip().splitlines()[0][:200]}"
|
||||
else:
|
||||
detail = _winrm_failure_detail(stdout, stderr, exit_code)
|
||||
message = f"WinRM command failed ({target}): {detail}"
|
||||
return WinRmCmdResult(
|
||||
ok=ok,
|
||||
message=message,
|
||||
target=target,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
|
||||
|
||||
_SYSTEM32_CMDS = {
|
||||
"hostname": "hostname.exe",
|
||||
"qwinsta": "qwinsta.exe",
|
||||
"logoff": "logoff.exe",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_winrm_cmd(remote_cmd: str) -> str:
|
||||
"""WinRM cmd shell may have minimal PATH — use explicit System32 for built-ins."""
|
||||
text = remote_cmd.strip()
|
||||
if not text:
|
||||
return text
|
||||
parts = text.split(None, 1)
|
||||
name = parts[0].lower()
|
||||
rest = parts[1] if len(parts) > 1 else ""
|
||||
exe = _SYSTEM32_CMDS.get(name)
|
||||
if exe:
|
||||
suffix = f" {rest}" if rest else ""
|
||||
return f"%SystemRoot%\\System32\\{exe}{suffix}"
|
||||
return text
|
||||
|
||||
|
||||
def run_winrm_ps(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
script: str,
|
||||
timeout_sec: int = 30,
|
||||
) -> WinRmCmdResult:
|
||||
target = target.strip()
|
||||
try:
|
||||
session, winrm = _winrm_session(target, user, password, timeout_sec=timeout_sec)
|
||||
result = session.run_ps(script)
|
||||
except winrm.exceptions.WinRMTransportError as exc:
|
||||
detail = str(exc)
|
||||
hint = ""
|
||||
if "credentials were rejected" in detail.lower():
|
||||
hint = " Проверьте логин (B26\\user), пароль и WinRM на ПК."
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"WinRM transport error ({target}): {detail}{hint}",
|
||||
target=target,
|
||||
)
|
||||
except (socket.timeout, TimeoutError):
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"WinRM timeout ({timeout_sec}s) to {target}:5985",
|
||||
target=target,
|
||||
)
|
||||
except WinAdminNotConfiguredError as exc:
|
||||
return WinRmCmdResult(ok=False, message=str(exc), target=target)
|
||||
except RuntimeError as exc:
|
||||
return WinRmCmdResult(ok=False, message=str(exc), target=target)
|
||||
except Exception as exc:
|
||||
return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target)
|
||||
|
||||
stdout = _decode_winrm_bytes(result.std_out or b"")
|
||||
stderr = _decode_winrm_bytes(result.std_err or b"")
|
||||
exit_code = int(result.status_code)
|
||||
stdout, stderr = _sanitize_winrm_streams(stdout, stderr)
|
||||
return _finalize_winrm_run(
|
||||
target=target,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
|
||||
|
||||
def run_winrm_cmd(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
remote_cmd: str,
|
||||
timeout_sec: int = 30,
|
||||
) -> WinRmCmdResult:
|
||||
target = target.strip()
|
||||
try:
|
||||
session, winrm = _winrm_session(target, user, password, timeout_sec=timeout_sec)
|
||||
result = session.run_cmd(_resolve_winrm_cmd(remote_cmd))
|
||||
except winrm.exceptions.WinRMTransportError as exc:
|
||||
detail = str(exc)
|
||||
hint = ""
|
||||
if "credentials were rejected" in detail.lower():
|
||||
hint = " Проверьте логин (B26\\user), пароль и WinRM на ПК."
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"WinRM transport error ({target}): {detail}{hint}",
|
||||
target=target,
|
||||
)
|
||||
except (socket.timeout, TimeoutError):
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"WinRM timeout ({timeout_sec}s) to {target}:5985",
|
||||
target=target,
|
||||
)
|
||||
except WinAdminNotConfiguredError as exc:
|
||||
return WinRmCmdResult(ok=False, message=str(exc), target=target)
|
||||
except RuntimeError as exc:
|
||||
return WinRmCmdResult(ok=False, message=str(exc), target=target)
|
||||
except Exception as exc:
|
||||
return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target)
|
||||
|
||||
stdout = _decode_winrm_bytes(result.std_out or b"")
|
||||
stderr = _decode_winrm_bytes(result.std_err or b"")
|
||||
exit_code = int(result.status_code)
|
||||
stdout, stderr = _sanitize_winrm_streams(stdout, stderr)
|
||||
return _finalize_winrm_run(
|
||||
target=target,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
|
||||
|
||||
def run_winrm_qwinsta(*, target: str, user: str, password: str) -> WinRmCmdResult:
|
||||
return run_winrm_cmd(target=target, user=user, password=password, remote_cmd="qwinsta", timeout_sec=45)
|
||||
|
||||
|
||||
def run_winrm_logoff(*, target: str, user: str, password: str, session_id: int) -> WinRmCmdResult:
|
||||
if session_id < 0:
|
||||
return WinRmCmdResult(ok=False, message="Invalid session_id", target=target)
|
||||
return run_winrm_cmd(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd=f"logoff {session_id} /v",
|
||||
timeout_sec=45,
|
||||
)
|
||||
|
||||
|
||||
def _powershell_script_prelude() -> str:
|
||||
return (
|
||||
"$ProgressPreference = 'SilentlyContinue'\n"
|
||||
"$InformationPreference = 'SilentlyContinue'\n"
|
||||
"$utf8NoBom = New-Object System.Text.UTF8Encoding $false\n"
|
||||
"[Console]::OutputEncoding = $utf8NoBom\n"
|
||||
"$OutputEncoding = $utf8NoBom\n"
|
||||
)
|
||||
|
||||
|
||||
def _powershell_literal_path(path: str) -> str:
|
||||
return path.replace("'", "''")
|
||||
|
||||
|
||||
def _wrap_powershell_body(body: str) -> str:
|
||||
indented = "\n".join(f" {line}" if line else "" for line in body.splitlines())
|
||||
return (
|
||||
_powershell_script_prelude()
|
||||
+ "try {\n"
|
||||
+ indented
|
||||
+ "\n} catch {\n"
|
||||
+ " Write-Output ('ERROR: ' + $_.Exception.Message)\n"
|
||||
+ " exit 1\n"
|
||||
+ "}\n"
|
||||
)
|
||||
|
||||
|
||||
def _custom_deploy_body(script_path: str) -> str:
|
||||
literal = _powershell_literal_path(script_path.strip())
|
||||
return (
|
||||
f"$p = '{literal}'\n"
|
||||
"if (-not (Test-Path -LiteralPath $p)) { throw \"Deploy script not found: $p\" }\n"
|
||||
"Write-Output \"Running: $p\"\n"
|
||||
"& $p"
|
||||
)
|
||||
|
||||
|
||||
def _rdp_staging_setup_ps() -> str:
|
||||
dirname = _powershell_literal_path(RDP_REMOTE_STAGING_DIRNAME)
|
||||
legacy = _powershell_literal_path(RDP_LEGACY_STAGING)
|
||||
return (
|
||||
f"$staging = Join-Path $env:TEMP '{dirname}'\n"
|
||||
f"$legacyStaging = '{legacy}'\n"
|
||||
"if (Test-Path -LiteralPath $legacyStaging) {\n"
|
||||
" Remove-Item -LiteralPath $legacyStaging -Recurse -Force -ErrorAction SilentlyContinue\n"
|
||||
"}\n"
|
||||
)
|
||||
|
||||
|
||||
def _prepare_staging_body() -> str:
|
||||
return (
|
||||
_rdp_staging_setup_ps()
|
||||
+ "if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }\n"
|
||||
+ "New-Item -ItemType Directory -Path $staging -Force | Out-Null\n"
|
||||
+ "Write-Output \"Staging ready: $staging\"\n"
|
||||
)
|
||||
|
||||
|
||||
def _deploy_from_staging_body() -> str:
|
||||
return (
|
||||
_rdp_staging_setup_ps()
|
||||
+ "$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n"
|
||||
+ "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n"
|
||||
+ "if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n"
|
||||
+ "$logPath = Join-Path $env:ProgramData 'RDP-login-monitor\\Logs\\deploy.log'\n"
|
||||
+ "Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n"
|
||||
+ "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $deploy -SourceShareRoot $staging\n"
|
||||
+ "if (Test-Path -LiteralPath $logPath) {\n"
|
||||
+ " Write-Output ''\n"
|
||||
+ " Write-Output '--- deploy.log ---'\n"
|
||||
+ " Get-Content -LiteralPath $logPath -Encoding UTF8 | Select-Object -Last 60\n"
|
||||
+ "}\n"
|
||||
)
|
||||
|
||||
|
||||
def _bundle_download_url(token: str) -> str:
|
||||
settings = get_settings()
|
||||
base = (settings.sac_agent_bundle_base_url or settings.sac_public_url).rstrip("/")
|
||||
return f"{base}/api/v1/agent/rdp-bundle/{token}"
|
||||
|
||||
|
||||
def _download_bundle_body(bundle_url: str) -> str:
|
||||
url = _powershell_literal_path(bundle_url)
|
||||
return (
|
||||
_rdp_staging_setup_ps()
|
||||
+ "if (-not (Test-Path -LiteralPath $staging)) { throw 'Staging directory missing' }\n"
|
||||
+ f"$url = '{url}'\n"
|
||||
+ "$zip = Join-Path $env:TEMP ('sac-rdp-bundle-' + [Guid]::NewGuid().ToString() + '.zip')\n"
|
||||
+ "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n"
|
||||
+ "Write-Output \"Downloading bundle: $url\"\n"
|
||||
+ "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n"
|
||||
+ "if (-not (Test-Path -LiteralPath $zip)) { throw 'Bundle download produced no file' }\n"
|
||||
+ "$zipSize = (Get-Item -LiteralPath $zip).Length\n"
|
||||
+ "if ($zipSize -lt 100) { throw \"Bundle too small ($zipSize bytes)\" }\n"
|
||||
+ "Add-Type -AssemblyName System.IO.Compression.FileSystem\n"
|
||||
+ "[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging)\n"
|
||||
+ "Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue\n"
|
||||
+ "Write-Output \"Bundle extracted to $staging\"\n"
|
||||
)
|
||||
|
||||
|
||||
def _fetch_rdp_bundle_dir(repo_url: str, git_branch: str) -> Path:
|
||||
settings = get_settings()
|
||||
return clone_or_update_repo(
|
||||
repo_url,
|
||||
git_branch,
|
||||
_cache_dir(),
|
||||
token=(settings.sac_agent_git_token or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
def run_winrm_rdp_monitor_update(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
script_path: str = "",
|
||||
repo_url: str = "",
|
||||
git_branch: str = "main",
|
||||
) -> WinRmCmdResult:
|
||||
target = target.strip()
|
||||
if script_path.strip():
|
||||
return run_winrm_ps(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
script=_wrap_powershell_body(_custom_deploy_body(script_path)),
|
||||
timeout_sec=900,
|
||||
)
|
||||
|
||||
effective_repo = (
|
||||
repo_url or "https://git.papatramp.ru/PapaTramp/RDP-login-monitor.git"
|
||||
).strip()
|
||||
effective_branch = (git_branch or "main").strip() or "main"
|
||||
if not effective_repo:
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message="rdp_git_repo_url is not configured in SAC",
|
||||
target=target,
|
||||
)
|
||||
|
||||
try:
|
||||
repo_dir = _fetch_rdp_bundle_dir(effective_repo, effective_branch)
|
||||
except Exception as exc:
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"SAC failed to fetch RDP bundle from git: {exc}",
|
||||
target=target,
|
||||
)
|
||||
|
||||
missing = [name for name in RDP_BUNDLE_REQUIRED if not (repo_dir / name).is_file()]
|
||||
if missing:
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=f"RDP bundle incomplete on SAC: missing {', '.join(missing)}",
|
||||
target=target,
|
||||
)
|
||||
|
||||
zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES)
|
||||
if not zip_bytes:
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message="RDP bundle zip is empty on SAC",
|
||||
target=target,
|
||||
)
|
||||
bundle_token = register_rdp_bundle_zip(zip_bytes)
|
||||
bundle_url = _bundle_download_url(bundle_token)
|
||||
staging_label = f"%TEMP%\\{RDP_REMOTE_STAGING_DIRNAME}"
|
||||
|
||||
prep = run_winrm_ps(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
script=_wrap_powershell_body(_prepare_staging_body()),
|
||||
timeout_sec=120,
|
||||
)
|
||||
if not prep.ok:
|
||||
return prep
|
||||
|
||||
download = run_winrm_ps(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
script=_wrap_powershell_body(_download_bundle_body(bundle_url)),
|
||||
timeout_sec=300,
|
||||
)
|
||||
if not download.ok:
|
||||
return WinRmCmdResult(
|
||||
ok=False,
|
||||
message=(
|
||||
f"Failed to download RDP bundle on client: {download.message} "
|
||||
"(bundle URL omitted from logs)"
|
||||
),
|
||||
target=target,
|
||||
stdout="\n\n".join(part.strip() for part in [prep.stdout, download.stdout] if part.strip()),
|
||||
stderr=download.stderr,
|
||||
exit_code=download.exit_code,
|
||||
)
|
||||
|
||||
deploy = run_winrm_ps(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
script=_wrap_powershell_body(_deploy_from_staging_body()),
|
||||
timeout_sec=900,
|
||||
)
|
||||
header = f"SAC served bundle from git; client staging {staging_label}"
|
||||
stdout = "\n\n".join(
|
||||
part.strip()
|
||||
for part in [header, prep.stdout, download.stdout, deploy.stdout]
|
||||
if part.strip()
|
||||
)
|
||||
return WinRmCmdResult(
|
||||
ok=deploy.ok,
|
||||
message=deploy.message,
|
||||
target=target,
|
||||
stdout=stdout,
|
||||
stderr=deploy.stderr,
|
||||
exit_code=deploy.exit_code,
|
||||
)
|
||||
|
||||
|
||||
def run_winrm_on_host_targets(
|
||||
host: Host,
|
||||
*,
|
||||
user: str,
|
||||
password: str,
|
||||
action,
|
||||
) -> tuple[WinRmCmdResult | None, list[str]]:
|
||||
"""Try WinRM targets in hostname-first order; action(target) -> WinRmCmdResult."""
|
||||
attempts: list[str] = []
|
||||
last: WinRmCmdResult | None = None
|
||||
for target in iter_winrm_targets(host):
|
||||
result = action(target=target)
|
||||
last = result
|
||||
attempts.append(f"{target}={'OK' if result.ok else 'fail'}")
|
||||
if result.ok:
|
||||
return result, attempts
|
||||
return last, attempts
|
||||
|
||||
|
||||
def resolve_windows_host_target(host: Host) -> str:
|
||||
targets = iter_winrm_targets(host)
|
||||
if not targets:
|
||||
raise HostTargetMissingError("Host has no hostname or IPv4 for WinRM test")
|
||||
return targets[0]
|
||||
|
||||
|
||||
def _netbios_name_variants(name: str | None) -> list[str]:
|
||||
"""Короткое имя ПК (NetBIOS), как Enter-PSSession -ComputerName Andrisonova-PC."""
|
||||
text = (name or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
short = text.split(".")[0].split("\\")[0].strip()
|
||||
if not short:
|
||||
return []
|
||||
if short.casefold() == text.casefold():
|
||||
return [short]
|
||||
return [short, text]
|
||||
|
||||
|
||||
def _looks_like_computer_name(value: str) -> bool:
|
||||
text = value.strip()
|
||||
if not text or " " in text:
|
||||
return False
|
||||
return len(text) <= 63
|
||||
|
||||
|
||||
def iter_winrm_targets(host: Host) -> list[str]:
|
||||
"""WinRM + NTLM: сначала имя хоста (как Enter-PSSession -ComputerName), IP — последним."""
|
||||
if not is_windows_host(host):
|
||||
raise HostNotWindowsError("Host is not Windows")
|
||||
|
||||
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)
|
||||
|
||||
for variant in _netbios_name_variants(host.hostname):
|
||||
add(variant)
|
||||
|
||||
inventory = host.inventory if isinstance(host.inventory, dict) else {}
|
||||
computer_name = inventory.get("computer_name")
|
||||
if isinstance(computer_name, str):
|
||||
for variant in _netbios_name_variants(computer_name):
|
||||
add(variant)
|
||||
|
||||
if host.display_name and _looks_like_computer_name(host.display_name):
|
||||
for variant in _netbios_name_variants(host.display_name):
|
||||
add(variant)
|
||||
|
||||
add(host.ipv4)
|
||||
return ordered
|
||||
|
||||
|
||||
def is_windows_host(host: Host) -> bool:
|
||||
if (host.os_family or "").strip().lower() == "windows":
|
||||
return True
|
||||
return (host.product or "").strip() == "rdp-login-monitor"
|
||||
|
||||
|
||||
def test_winrm_connection(
|
||||
*,
|
||||
target: str,
|
||||
user: str,
|
||||
password: str,
|
||||
timeout_sec: int = 15,
|
||||
) -> WinRmTestResult:
|
||||
result = run_winrm_cmd(
|
||||
target=target,
|
||||
user=user,
|
||||
password=password,
|
||||
remote_cmd="hostname",
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
if result.ok and result.stdout.strip():
|
||||
host = result.stdout.strip().splitlines()[0]
|
||||
return WinRmTestResult(
|
||||
ok=True,
|
||||
message=f"WinRM OK, hostname={host}",
|
||||
target=result.target,
|
||||
hostname=host,
|
||||
)
|
||||
return WinRmTestResult(
|
||||
ok=result.ok,
|
||||
message=result.message,
|
||||
target=result.target,
|
||||
hostname=None,
|
||||
)
|
||||
Reference in New Issue
Block a user