Files
default/telegram-agy-bot/ssh_manager.py
T

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()