chore(home): mirror from kalinamall (9883e6a) with papatramp URLs

This commit is contained in:
2026-07-14 20:43:52 +10:00
commit ed4e78f6c3
312 changed files with 42790 additions and 0 deletions
+109
View File
@@ -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))
+308
View File
@@ -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,
)
+74
View File
@@ -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
+31
View File
@@ -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],
}
+325
View File
@@ -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
+149
View File
@@ -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
+27
View File
@@ -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]
+368
View File
@@ -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
+492
View File
@@ -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
+131
View File
@@ -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)
+50
View File
@@ -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)
+57
View File
@@ -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
+132
View File
@@ -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})")
+51
View File
@@ -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),
)
+100
View File
@@ -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
+162
View File
@@ -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
+127
View File
@@ -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)
+449
View File
@@ -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"),
}
+578
View File
@@ -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
+290
View File
@@ -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
+142
View File
@@ -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
+146
View File
@@ -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
+104
View File
@@ -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()
+296
View File
@@ -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)
+244
View File
@@ -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,
)
+58
View File
@@ -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)
+68
View File
@@ -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)
+140
View File
@@ -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)
+245
View File
@@ -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()
+220
View File
@@ -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)
+119
View File
@@ -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
+50
View File
@@ -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
+132
View File
@@ -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)
+311
View File
@@ -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
+182
View File
@@ -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
+33
View File
@@ -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)
+148
View File
@@ -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
+39
View File
@@ -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(),
}
+24
View File
@@ -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]
+37
View File
@@ -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
+588
View File
@@ -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)
+115
View File
@@ -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,
},
}
+105
View File
@@ -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)
+604
View File
@@ -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")
+30
View File
@@ -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)
+96
View File
@@ -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)
+136
View File
@@ -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)
+109
View File
@@ -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
+692
View File
@@ -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,
)