720 lines
25 KiB
Python
720 lines
25 KiB
Python
import os
|
|
import sys
|
|
import time
|
|
import fnmatch
|
|
import logging
|
|
import asyncio
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any, List, Tuple, Set
|
|
import socket
|
|
import subprocess
|
|
import tempfile
|
|
import shutil
|
|
import io
|
|
|
|
import paramiko
|
|
from remote_audit import remote_audit
|
|
|
|
logger = logging.getLogger("AGYSSHManager")
|
|
|
|
# Standard patterns for junk, temporary, local configs, and production user data that must NEVER be overwritten or pushed
|
|
DEFAULT_SSH_EXCLUDES = [
|
|
# Git and version control
|
|
".git",
|
|
".git/*",
|
|
".gitignore",
|
|
".gitattributes",
|
|
".gitmodules",
|
|
|
|
# Environment & local secret files
|
|
".env",
|
|
".env.*",
|
|
"*.env",
|
|
"*.pem",
|
|
"*.key",
|
|
|
|
# User databases and data stores (never overwrite production sqlite / db files!)
|
|
"*.db",
|
|
"*.db-shm",
|
|
"*.db-wal",
|
|
"*.sqlite",
|
|
"*.sqlite3",
|
|
"data/*.db",
|
|
"data/*.sqlite",
|
|
|
|
# Dynamic uploads & media directories (production user uploads must never be touched)
|
|
"uploads",
|
|
"uploads/*",
|
|
"*/uploads",
|
|
"*/uploads/*",
|
|
"media",
|
|
"media/*",
|
|
"storage/uploads",
|
|
"storage/uploads/*",
|
|
"storage/framework",
|
|
"storage/framework/*",
|
|
|
|
# AI & Bot internal session / agent configs
|
|
".agents",
|
|
".agents/*",
|
|
".gemini",
|
|
".gemini/*",
|
|
"sessions_data",
|
|
"sessions_data/*",
|
|
"uploads_temp",
|
|
"uploads_temp/*",
|
|
|
|
# Python cache & virtual environments
|
|
"__pycache__",
|
|
"__pycache__/*",
|
|
"*.pyc",
|
|
"*.pyo",
|
|
"*.pyd",
|
|
".venv",
|
|
".venv/*",
|
|
"venv",
|
|
"venv/*",
|
|
"env",
|
|
"env/*",
|
|
|
|
# Node.js dependencies
|
|
"node_modules",
|
|
"node_modules/*",
|
|
|
|
# Logs & temporary files
|
|
"*.log",
|
|
"logs",
|
|
"logs/*",
|
|
"tmp",
|
|
"tmp/*",
|
|
"temp",
|
|
"temp/*",
|
|
|
|
# IDE & OS files
|
|
".DS_Store",
|
|
"Thumbs.db",
|
|
"desktop.ini",
|
|
".idea",
|
|
".idea/*",
|
|
".vscode",
|
|
".vscode/*",
|
|
"*.swp",
|
|
"*.swo",
|
|
"*~",
|
|
]
|
|
|
|
|
|
def should_exclude_ssh(rel_path: str, custom_excludes: Optional[List[str]] = None) -> bool:
|
|
"""Checks if a relative path matches any exclusion pattern for SSH sync."""
|
|
excludes = DEFAULT_SSH_EXCLUDES + (custom_excludes or [])
|
|
norm_path = rel_path.replace("\\", "/").strip("/")
|
|
parts = [p for p in norm_path.split("/") if p]
|
|
|
|
for pattern in excludes:
|
|
pat = pattern.replace("\\", "/").strip("/")
|
|
clean_pat = pat.rstrip("/*")
|
|
|
|
if fnmatch.fnmatch(norm_path, pat) or fnmatch.fnmatch(norm_path, clean_pat):
|
|
return True
|
|
|
|
if norm_path == clean_pat or norm_path.startswith(clean_pat + "/"):
|
|
return True
|
|
|
|
for part in parts:
|
|
if fnmatch.fnmatch(part, pat) or fnmatch.fnmatch(part, clean_pat):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
class SSHManager:
|
|
"""Manages SSH connections, remote dev synchronization, execution, and audit logging."""
|
|
|
|
def _create_ssh_client(
|
|
self,
|
|
host: str,
|
|
port: int = 22,
|
|
user: str = "",
|
|
password: Optional[str] = None,
|
|
key_content_or_path: Optional[str] = None,
|
|
timeout: int = 15,
|
|
) -> paramiko.SSHClient:
|
|
"""Helper to create and authenticate a paramiko SSHClient."""
|
|
clean_host = host.strip()
|
|
clean_user = user.strip() if user else "root"
|
|
clean_pass = password.strip() if password else None
|
|
clean_port = int(port) if port else 22
|
|
|
|
client = paramiko.SSHClient()
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
|
|
pkey = None
|
|
if key_content_or_path and key_content_or_path.strip():
|
|
raw_k = key_content_or_path.strip()
|
|
# Check if key is a file path
|
|
if os.path.isfile(raw_k):
|
|
try:
|
|
pkey = paramiko.RSAKey.from_private_key_file(raw_k, password=clean_pass)
|
|
except Exception:
|
|
try:
|
|
pkey = paramiko.Ed25519Key.from_private_key_file(raw_k, password=clean_pass)
|
|
except Exception:
|
|
try:
|
|
pkey = paramiko.ECDSAKey.from_private_key_file(raw_k, password=clean_pass)
|
|
except Exception as e:
|
|
logger.warning(f"Could not load private key from file {raw_k}: {e}")
|
|
else:
|
|
# Key is provided as string text
|
|
key_file_obj = io.StringIO(raw_k)
|
|
for key_cls in (paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.DSSKey):
|
|
try:
|
|
key_file_obj.seek(0)
|
|
pkey = key_cls.from_private_key(key_file_obj, password=clean_pass)
|
|
if pkey:
|
|
break
|
|
except Exception:
|
|
continue
|
|
|
|
connect_kwargs: Dict[str, Any] = {
|
|
"hostname": clean_host,
|
|
"port": clean_port,
|
|
"username": clean_user,
|
|
"timeout": timeout,
|
|
"banner_timeout": timeout,
|
|
"auth_timeout": timeout,
|
|
"allow_agent": False,
|
|
"look_for_keys": False,
|
|
}
|
|
|
|
if pkey:
|
|
connect_kwargs["pkey"] = pkey
|
|
elif clean_pass:
|
|
connect_kwargs["password"] = clean_pass
|
|
else:
|
|
# Try agent / default keys if neither provided
|
|
connect_kwargs["allow_agent"] = True
|
|
connect_kwargs["look_for_keys"] = True
|
|
|
|
client.connect(**connect_kwargs)
|
|
return client
|
|
|
|
async def test_connection(
|
|
self,
|
|
host: str,
|
|
port: int = 22,
|
|
user: str = "",
|
|
password: Optional[str] = None,
|
|
key: Optional[str] = None,
|
|
path: str = "/",
|
|
timeout: int = 12,
|
|
user_id: int = 0,
|
|
project_name: str = "",
|
|
requester: str = "user_button",
|
|
) -> Tuple[bool, str]:
|
|
"""Tests SSH connection and checks remote path accessibility."""
|
|
start_t = time.time()
|
|
if not host or not host.strip():
|
|
return False, "آدرس هاست (Host) سرور SSH مشخص نشده است."
|
|
|
|
def _test():
|
|
client = None
|
|
try:
|
|
client = self._create_ssh_client(
|
|
host=host.strip(),
|
|
port=port,
|
|
user=user.strip(),
|
|
password=password,
|
|
key_content_or_path=key,
|
|
timeout=timeout,
|
|
)
|
|
|
|
# Run simple probe command
|
|
stdin, stdout, stderr = client.exec_command("uname -sr || echo Linux", timeout=timeout)
|
|
os_info = stdout.read().decode("utf-8", errors="ignore").strip()
|
|
|
|
# Test SFTP session and target path
|
|
sftp = client.open_sftp()
|
|
target_path = (path or "/").strip()
|
|
path_status = "موجود است"
|
|
item_count = 0
|
|
|
|
try:
|
|
sftp.stat(target_path)
|
|
try:
|
|
dir_items = sftp.listdir(target_path)
|
|
item_count = len(dir_items)
|
|
except Exception:
|
|
item_count = 0
|
|
except IOError:
|
|
path_status = "موجود نیست (در اولین استقرار به صورت خودکار ایجاد میشود)"
|
|
|
|
sftp.close()
|
|
client.close()
|
|
|
|
msg = (
|
|
f"اتصال SSH با موفقیت برقرار شد ✅\n"
|
|
f"• 🖥️ <b>سیستمعامل:</b> <code>{os_info}</code>\n"
|
|
f"• 📂 <b>مسیر ریموت:</b> <code>{target_path}</code> ({path_status})\n"
|
|
f"• 📊 <b>تعداد آیتمها در مسیر:</b> <code>{item_count}</code>"
|
|
)
|
|
return True, msg
|
|
|
|
except socket.gaierror as e:
|
|
return False, f"نام دامنه یا IP سرور یافت نشد ({host}): {e}"
|
|
except (socket.timeout, TimeoutError) as e:
|
|
return False, f"مهلت زمانی اتصال SSH به پایان رسید (Timeout روی پورت {port}): {e}"
|
|
except ConnectionRefusedError as e:
|
|
return False, f"اتصال SSH توسط سرور رد شد (پورت {port} مسدود است یا SSH اجرا نمیشود)."
|
|
except paramiko.AuthenticationException as e:
|
|
return False, f"خطای احراز هویت SSH: نام کاربری، رمز عبور یا کلید نامعتبر است ({e})."
|
|
except Exception as e:
|
|
return False, f"خطا در برقراری ارتباط SSH: {e}"
|
|
finally:
|
|
if client:
|
|
try:
|
|
client.close()
|
|
except Exception:
|
|
pass
|
|
|
|
success, result_msg = await asyncio.to_thread(_test)
|
|
dur = (time.time() - start_t) * 1000
|
|
|
|
# Record audit log
|
|
remote_audit.log_operation(
|
|
user_id=user_id,
|
|
project_name=project_name or "unknown",
|
|
service_type="ssh",
|
|
action_type="test_connection",
|
|
requester=requester,
|
|
status="success" if success else "failed",
|
|
details=f"Host: {host}:{port}, Path: {path}",
|
|
result_summary=result_msg[:200],
|
|
duration_ms=dur,
|
|
)
|
|
|
|
return success, result_msg
|
|
|
|
async def push_project(
|
|
self,
|
|
workspace_path: str,
|
|
host: str,
|
|
port: int = 22,
|
|
user: str = "",
|
|
password: Optional[str] = None,
|
|
key: Optional[str] = None,
|
|
remote_path: str = "/",
|
|
branch: str = "production",
|
|
custom_excludes: Optional[List[str]] = None,
|
|
user_id: int = 0,
|
|
project_name: str = "",
|
|
requester: str = "user_button",
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Pushes files from the git branch (strictly 'production') to remote server via SFTP.
|
|
Ensures existing production user data, SQLite databases, and dynamic uploads are NEVER modified.
|
|
"""
|
|
start_t = time.time()
|
|
ws = Path(workspace_path).expanduser().resolve()
|
|
if not ws.exists() or not ws.is_dir():
|
|
err = f"دایرکتوری محلی پروژه در مسیر {workspace_path} یافت نشد."
|
|
remote_audit.log_operation(
|
|
user_id=user_id,
|
|
project_name=project_name,
|
|
service_type="ssh",
|
|
action_type="push",
|
|
requester=requester,
|
|
status="failed",
|
|
details=f"Push branch={branch} to {host}:{port}{remote_path}",
|
|
result_summary=err,
|
|
)
|
|
return {
|
|
"success": False,
|
|
"error": err,
|
|
"files_uploaded": 0,
|
|
"files_skipped": 0,
|
|
"bytes_transferred": 0,
|
|
"duration": 0.0,
|
|
}
|
|
|
|
def _run_push():
|
|
client = None
|
|
temp_export_dir = None
|
|
uploaded_files = []
|
|
skipped_files = []
|
|
total_bytes = 0
|
|
|
|
try:
|
|
# 1. Extract strictly from production git branch
|
|
source_dir = ws
|
|
if (ws / ".git").exists():
|
|
try:
|
|
branches_proc = subprocess.run(
|
|
["git", "branch", "--list", branch],
|
|
cwd=str(ws),
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
target_branch = branch if branch in branches_proc.stdout else "HEAD"
|
|
|
|
temp_export_dir = Path(tempfile.mkdtemp(prefix="ssh_push_prod_"))
|
|
archive_proc = subprocess.Popen(
|
|
["git", "archive", target_branch],
|
|
cwd=str(ws),
|
|
stdout=subprocess.PIPE,
|
|
)
|
|
tar_proc = subprocess.Popen(
|
|
["tar", "-x", "-C", str(temp_export_dir)],
|
|
stdin=archive_proc.stdout,
|
|
)
|
|
archive_proc.stdout.close()
|
|
tar_proc.communicate()
|
|
|
|
if tar_proc.returncode == 0:
|
|
source_dir = temp_export_dir
|
|
except Exception as ge:
|
|
logger.warning(f"Git archive export failed for {branch} (using ws): {ge}")
|
|
source_dir = ws
|
|
|
|
client = self._create_ssh_client(
|
|
host=host.strip(),
|
|
port=port,
|
|
user=user.strip(),
|
|
password=password,
|
|
key_content_or_path=key,
|
|
timeout=25,
|
|
)
|
|
sftp = client.open_sftp()
|
|
|
|
# Prepare remote path
|
|
target_root = (remote_path or "/").strip().replace("\\", "/")
|
|
if not target_root.startswith("/"):
|
|
target_root = "/" + target_root
|
|
target_root = target_root.rstrip("/")
|
|
if not target_root:
|
|
target_root = "/"
|
|
|
|
def ensure_remote_dir(r_dir: str):
|
|
if r_dir in ("/", ""):
|
|
return
|
|
parts = [p for p in r_dir.split("/") if p]
|
|
curr = ""
|
|
for p in parts:
|
|
curr += "/" + p
|
|
try:
|
|
sftp.stat(curr)
|
|
except IOError:
|
|
try:
|
|
sftp.mkdir(curr)
|
|
except Exception:
|
|
pass
|
|
|
|
ensure_remote_dir(target_root)
|
|
|
|
# Scan and filter local files
|
|
for root, dirs, files in os.walk(str(source_dir)):
|
|
rel_dir = os.path.relpath(root, str(source_dir))
|
|
if rel_dir == ".":
|
|
rel_dir = ""
|
|
|
|
dirs_to_keep = []
|
|
for d in dirs:
|
|
dir_rel = f"{rel_dir}/{d}".strip("/")
|
|
if should_exclude_ssh(dir_rel, custom_excludes):
|
|
skipped_files.append(dir_rel + "/")
|
|
else:
|
|
dirs_to_keep.append(d)
|
|
dirs[:] = dirs_to_keep
|
|
|
|
for f in files:
|
|
file_rel = f"{rel_dir}/{f}".strip("/")
|
|
if should_exclude_ssh(file_rel, custom_excludes):
|
|
skipped_files.append(file_rel)
|
|
else:
|
|
local_file = Path(root) / f
|
|
remote_file_path = f"{target_root}/{file_rel}".replace("//", "/")
|
|
remote_file_dir = os.path.dirname(remote_file_path)
|
|
|
|
ensure_remote_dir(remote_file_dir)
|
|
sftp.put(str(local_file), remote_file_path)
|
|
|
|
try:
|
|
f_size = local_file.stat().st_size
|
|
total_bytes += f_size
|
|
except Exception:
|
|
pass
|
|
uploaded_files.append(file_rel)
|
|
|
|
sftp.close()
|
|
client.close()
|
|
|
|
dur = time.time() - start_t
|
|
return {
|
|
"success": True,
|
|
"files_uploaded": len(uploaded_files),
|
|
"files_skipped": len(skipped_files),
|
|
"bytes_transferred": total_bytes,
|
|
"duration": dur,
|
|
"uploaded_list": uploaded_files,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"SSH push failed: {e}", exc_info=True)
|
|
dur = time.time() - start_t
|
|
return {
|
|
"success": False,
|
|
"error": f"خطا در ارسال فایلها به سرور SSH: {e}",
|
|
"files_uploaded": len(uploaded_files),
|
|
"files_skipped": len(skipped_files),
|
|
"bytes_transferred": total_bytes,
|
|
"duration": dur,
|
|
}
|
|
finally:
|
|
if client:
|
|
try:
|
|
client.close()
|
|
except Exception:
|
|
pass
|
|
if temp_export_dir and os.path.exists(temp_export_dir):
|
|
shutil.rmtree(temp_export_dir, ignore_errors=True)
|
|
|
|
res = await asyncio.to_thread(_run_push)
|
|
|
|
# Audit log record
|
|
status_str = "success" if res.get("success") else "failed"
|
|
summary_str = f"Uploaded {res.get('files_uploaded', 0)} files ({res.get('bytes_transferred', 0)} bytes)" if res.get("success") else str(res.get("error", "Error"))
|
|
remote_audit.log_operation(
|
|
user_id=user_id,
|
|
project_name=project_name,
|
|
service_type="ssh",
|
|
action_type="push",
|
|
requester=requester,
|
|
status=status_str,
|
|
details=f"Push to {host}:{port}{remote_path} (Branch: {branch})",
|
|
result_summary=summary_str[:200],
|
|
duration_ms=res.get("duration", 0.0) * 1000,
|
|
)
|
|
|
|
return res
|
|
|
|
async def pull_project(
|
|
self,
|
|
workspace_path: str,
|
|
host: str,
|
|
port: int = 22,
|
|
user: str = "",
|
|
password: Optional[str] = None,
|
|
key: Optional[str] = None,
|
|
remote_path: str = "/",
|
|
branch: str = "production",
|
|
custom_excludes: Optional[List[str]] = None,
|
|
user_id: int = 0,
|
|
project_name: str = "",
|
|
requester: str = "user_button",
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Pulls files from the remote SSH server into local workspace / production branch.
|
|
Skips remote database and user upload files according to exclusion rules.
|
|
"""
|
|
start_t = time.time()
|
|
ws = Path(workspace_path).expanduser().resolve()
|
|
ws.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _run_pull():
|
|
client = None
|
|
downloaded_files = []
|
|
skipped_files = []
|
|
total_bytes = 0
|
|
|
|
try:
|
|
client = self._create_ssh_client(
|
|
host=host.strip(),
|
|
port=port,
|
|
user=user.strip(),
|
|
password=password,
|
|
key_content_or_path=key,
|
|
timeout=25,
|
|
)
|
|
sftp = client.open_sftp()
|
|
|
|
target_root = (remote_path or "/").strip().replace("\\", "/").rstrip("/")
|
|
if not target_root:
|
|
target_root = "/"
|
|
|
|
def recursive_download(r_dir: str, rel_dir: str = ""):
|
|
nonlocal total_bytes
|
|
try:
|
|
entries = sftp.listdir_attr(r_dir)
|
|
except IOError as e:
|
|
logger.warning(f"Cannot list remote dir {r_dir}: {e}")
|
|
return
|
|
|
|
for entry in entries:
|
|
fname = entry.filename
|
|
if fname in (".", ".."):
|
|
continue
|
|
|
|
cur_rel = f"{rel_dir}/{fname}".strip("/")
|
|
cur_remote = f"{r_dir}/{fname}".replace("//", "/")
|
|
|
|
# Check if directory
|
|
import stat
|
|
is_dir = stat.S_ISDIR(entry.st_mode)
|
|
|
|
if should_exclude_ssh(cur_rel if not is_dir else cur_rel + "/", custom_excludes):
|
|
skipped_files.append(cur_rel + ("/" if is_dir else ""))
|
|
continue
|
|
|
|
if is_dir:
|
|
local_sub = ws / cur_rel
|
|
local_sub.mkdir(parents=True, exist_ok=True)
|
|
recursive_download(cur_remote, cur_rel)
|
|
else:
|
|
local_dest = ws / cur_rel
|
|
local_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
sftp.get(cur_remote, str(local_dest))
|
|
total_bytes += entry.st_size
|
|
downloaded_files.append(cur_rel)
|
|
|
|
recursive_download(target_root)
|
|
sftp.close()
|
|
client.close()
|
|
|
|
dur = time.time() - start_t
|
|
return {
|
|
"success": True,
|
|
"files_downloaded": len(downloaded_files),
|
|
"files_skipped": len(skipped_files),
|
|
"bytes_transferred": total_bytes,
|
|
"duration": dur,
|
|
"downloaded_list": downloaded_files,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"SSH pull failed: {e}", exc_info=True)
|
|
dur = time.time() - start_t
|
|
return {
|
|
"success": False,
|
|
"error": f"خطا در دریافت فایلها از سرور SSH: {e}",
|
|
"files_downloaded": len(downloaded_files),
|
|
"files_skipped": len(skipped_files),
|
|
"bytes_transferred": total_bytes,
|
|
"duration": dur,
|
|
}
|
|
finally:
|
|
if client:
|
|
try:
|
|
client.close()
|
|
except Exception:
|
|
pass
|
|
|
|
res = await asyncio.to_thread(_run_pull)
|
|
|
|
# Audit log record
|
|
status_str = "success" if res.get("success") else "failed"
|
|
summary_str = f"Downloaded {res.get('files_downloaded', 0)} files ({res.get('bytes_transferred', 0)} bytes)" if res.get("success") else str(res.get("error", "Error"))
|
|
remote_audit.log_operation(
|
|
user_id=user_id,
|
|
project_name=project_name,
|
|
service_type="ssh",
|
|
action_type="pull",
|
|
requester=requester,
|
|
status=status_str,
|
|
details=f"Pull from {host}:{port}{remote_path} to {workspace_path}",
|
|
result_summary=summary_str[:200],
|
|
duration_ms=res.get("duration", 0.0) * 1000,
|
|
)
|
|
|
|
return res
|
|
|
|
async def execute_command(
|
|
self,
|
|
host: str,
|
|
port: int = 22,
|
|
user: str = "",
|
|
password: Optional[str] = None,
|
|
key: Optional[str] = None,
|
|
remote_path: str = "/",
|
|
command: str = "",
|
|
timeout: int = 60,
|
|
user_id: int = 0,
|
|
project_name: str = "",
|
|
requester: str = "user_button",
|
|
) -> Tuple[bool, str, int]:
|
|
"""
|
|
Executes a bash command remotely inside the specified remote_path directory.
|
|
"""
|
|
start_t = time.time()
|
|
if not command or not command.strip():
|
|
return False, "دستور اجرایی خالی است.", -1
|
|
|
|
def _run_exec():
|
|
client = None
|
|
try:
|
|
client = self._create_ssh_client(
|
|
host=host.strip(),
|
|
port=port,
|
|
user=user.strip(),
|
|
password=password,
|
|
key_content_or_path=key,
|
|
timeout=timeout,
|
|
)
|
|
|
|
cd_prefix = f"cd {remote_path} && " if remote_path and remote_path != "/" else ""
|
|
full_cmd = f"bash -c {subprocess.list2cmdline([cd_prefix + command])}" if cd_prefix else command
|
|
|
|
stdin, stdout, stderr = client.exec_command(full_cmd, timeout=timeout)
|
|
out = stdout.read().decode("utf-8", errors="ignore")
|
|
err = stderr.read().decode("utf-8", errors="ignore")
|
|
exit_code = stdout.channel.recv_exit_status()
|
|
|
|
client.close()
|
|
|
|
combined = ""
|
|
if out:
|
|
combined += out
|
|
if err:
|
|
if combined:
|
|
combined += "\n"
|
|
combined += f"[STDERR]\n{err}"
|
|
|
|
if not combined:
|
|
combined = f"(دستور با کد {exit_code} بدون خروجی اجرا شد)"
|
|
|
|
# Limit output size to prevent overflow
|
|
if len(combined) > 3500:
|
|
combined = combined[:3500] + "\n... (خروجی طولانی کوتاه شد)"
|
|
|
|
return (exit_code == 0), combined, exit_code
|
|
|
|
except Exception as e:
|
|
logger.error(f"SSH command execution failed: {e}")
|
|
return False, f"خطا در اجرای فرمان SSH: {e}", -1
|
|
finally:
|
|
if client:
|
|
try:
|
|
client.close()
|
|
except Exception:
|
|
pass
|
|
|
|
success, output, code = await asyncio.to_thread(_run_exec)
|
|
dur = (time.time() - start_t) * 1000
|
|
|
|
# Audit log record
|
|
remote_audit.log_operation(
|
|
user_id=user_id,
|
|
project_name=project_name,
|
|
service_type="ssh",
|
|
action_type="exec",
|
|
requester=requester,
|
|
status="success" if success else "failed",
|
|
details=f"cmd: {command[:200]}",
|
|
result_summary=f"exit_code={code}, output={output[:100]}",
|
|
duration_ms=dur,
|
|
)
|
|
|
|
return success, output, code
|
|
|
|
|
|
# Global singleton instance
|
|
ssh_manager = SSHManager()
|