7864ab404b
**目录结构**: - scripts/ 顶层运维/工具脚本(20 个) - scripts/deploy/ 一键部署脚本(deploy_*.py / upload_*.py / redeploy.py 等 8 个) - scripts/fix/ 一次性修复脚本(fix_*.py / fix_*.cypher / fix_*.sql 等 9 个) - scripts/debug/ 调试脚本集合(check_*.py/sql、test_*.py、debug_*.py 等 42 个) **ops-tools/jms_ops.py**:jumpserver-ops skill 默认路径修复 - 从 'jumpserver-automation-shareable' 改为 'jumpserver-ops' - 同步更新注释(优先 JP_SKILL_DIR 环境变量) **典型脚本用途**: - scripts/deploy-v1.2.ps1 / -staging.ps1:一键部署 v1.2 到生产 / staging - scripts/init_quick_rules.sql:quick_rules 表初始数据 - scripts/gen_admin_token.py:生成 admin JWT token(调试用) - scripts/build-and-verify.sh:v1.1 实施验证脚本 - scripts/verify_*.sh / .py:API/quick_rules 验证 - scripts/fix/*.py:环境修复(compose、env_key、itsm_bridge 等) 合计 81 文件 + 3317 行 / - 1 行
700 lines
26 KiB
Python
700 lines
26 KiB
Python
"""
|
|
JumpServer 运维工具集 — 统一入口
|
|
|
|
整合能力:
|
|
1. exec — 远程命令执行 (单命令/批量/并行)
|
|
2. upload — 文件上传 (base64 通道, 小文件)
|
|
3. download — 文件下载 (base64 通道, 小文件)
|
|
4. batch — 批量命令复用会话 (一次登录, 多命令)
|
|
|
|
技术栈:
|
|
- Playwright (登录 + cookies)
|
|
- REST API (Connection Token)
|
|
- plink PTY (命令执行)
|
|
- base64 编码 (文件传输)
|
|
|
|
用法:
|
|
python jms_ops.py exec -c "hostname"
|
|
python jms_ops.py exec -c "hostname" -c "uptime" -c "docker ps"
|
|
python jms_ops.py exec -c "hostname" -c "uptime" --parallel
|
|
python jms_ops.py upload local_file.txt /tmp/remote_file.txt
|
|
python jms_ops.py download /tmp/remote_file.txt local_file.txt
|
|
python jms_ops.py batch -f commands.txt
|
|
"""
|
|
import sys, os, json, base64, time, pyotp, re, subprocess, threading, argparse, hashlib
|
|
from pathlib import Path
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
try:
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
sys.stderr.reconfigure(encoding="utf-8")
|
|
except: pass
|
|
|
|
# ============================================================
|
|
# 配置
|
|
# ============================================================
|
|
# 动态路径:基于环境变量或自动检测
|
|
_WORKBUDDY_DIR = Path(os.environ.get("WORKBUDDY_DIR", Path.home() / ".workbuddy"))
|
|
# 优先使用 JP_SKILL_DIR 环境变量,否则默认使用 jumpserver-ops 技能目录
|
|
_SKILL_DIR = Path(os.environ.get("JP_SKILL_DIR", _WORKBUDDY_DIR / "skills" / "jumpserver-ops"))
|
|
CONFIG_PATH = _SKILL_DIR / "config" / "jumpserver_config.json"
|
|
OTP_SECRET_PATH = _SKILL_DIR / "scripts" / "otp_secret.key"
|
|
OUTPUT_DIR = _SKILL_DIR / "scripts" / "webcli_output"
|
|
USER_DATA_DIR = str(Path(os.environ.get("TEMP", os.path.join(str(Path.home()), "AppData", "Local", "Temp"))) / "chrome-jumpserver-v10")
|
|
CDP_URL = os.environ.get("CDP_URL", "http://localhost:9224")
|
|
|
|
SSH_GATEWAY = "jumpserver.dc.servyou-it.com"
|
|
SSH_PORT = "2222"
|
|
PLINK_EXE = r"C:\Program Files\PuTTY\plink.exe"
|
|
|
|
# ANSI 转义码 (含 CSI + OSC)
|
|
ANSI_ESCAPE_RE = re.compile(
|
|
r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\))'
|
|
)
|
|
# shell prompt: [admin@host ~]$
|
|
PROMPT_RE = re.compile(r'\[[^\]]+@[^\]]+\][^\$#]*[\$#]\s*$')
|
|
|
|
# ============================================================
|
|
# 配置加载
|
|
# ============================================================
|
|
|
|
def load_config():
|
|
with open(CONFIG_PATH) as f: cfg = json.load(f)
|
|
return cfg["url"].rstrip("/"), cfg.get("username", "sxn"), base64.b64decode(cfg["password"]).decode("utf-8")
|
|
|
|
def load_otp():
|
|
return OTP_SECRET_PATH.read_text().strip() if OTP_SECRET_PATH.exists() else None
|
|
|
|
def strip_ansi(text):
|
|
return ANSI_ESCAPE_RE.sub('', text)
|
|
|
|
|
|
# ============================================================
|
|
# 浏览器登录 + REST API (获取 Connection Token)
|
|
# ============================================================
|
|
|
|
def ensure_logged_in(context, url, username, password, otp_code):
|
|
"""确保 workbench tab 已登录"""
|
|
workbench = None
|
|
for pg in context.pages:
|
|
if 'workbench' in pg.url.lower():
|
|
workbench = pg
|
|
break
|
|
if not workbench:
|
|
workbench = context.new_page()
|
|
workbench.goto(f"{url}/users/login/", wait_until="networkidle", timeout=30000)
|
|
workbench.wait_for_timeout(2000)
|
|
if workbench.locator('input[name="username"]').count() > 0:
|
|
workbench.fill('input[name="username"]', username)
|
|
workbench.fill('input[type="password"]', password)
|
|
workbench.click('button[type="submit"]')
|
|
workbench.wait_for_timeout(3000)
|
|
if workbench.locator('input[name="code"]').count() > 0:
|
|
workbench.locator('input[name="code"]').fill(str(otp_code))
|
|
workbench.locator('#submit_button, button[type="submit"]').first.click()
|
|
for i in range(40):
|
|
if workbench.locator('input[name="code"]').count() == 0:
|
|
break
|
|
try:
|
|
body_text = workbench.evaluate("() => document.body ? document.body.innerText : ''")
|
|
if any(kw in body_text for kw in ['验证码错误', 'OTP 错误', '已过期', 'expired', 'Invalid']):
|
|
break
|
|
except: pass
|
|
workbench.wait_for_timeout(500)
|
|
for _ in range(60):
|
|
if 'workbench' in workbench.url.lower():
|
|
break
|
|
workbench.wait_for_timeout(500)
|
|
return workbench
|
|
|
|
|
|
def get_connection_tokens(count=1):
|
|
"""
|
|
获取 N 个 Connection Token
|
|
|
|
一次 Playwright 登录 → REST API 创建 N 个 token
|
|
返回: [(token_id, token_secret), ...]
|
|
"""
|
|
import requests
|
|
url, username, password = load_config()
|
|
otp = pyotp.TOTP(load_otp()).now() if load_otp() else None
|
|
|
|
with sync_playwright() as p:
|
|
try:
|
|
browser = p.chromium.connect_over_cdp(CDP_URL)
|
|
contexts = browser.contexts
|
|
context = contexts[0] if contexts else browser.new_context()
|
|
except Exception as e:
|
|
context = p.chromium.launch_persistent_context(
|
|
USER_DATA_DIR, headless=False, channel="chrome",
|
|
args=["--no-sandbox", "--remote-debugging-port=9224"],
|
|
ignore_https_errors=True,
|
|
)
|
|
|
|
workbench = ensure_logged_in(context, url, username, password, otp)
|
|
if not workbench:
|
|
print("❌ 登录失败")
|
|
return []
|
|
|
|
session = requests.Session()
|
|
csrf_token = None
|
|
for c in context.cookies():
|
|
session.cookies.set(c['name'], c['value'], domain=c.get('domain', ''), path=c.get('path', '/'))
|
|
if c['name'] == 'jms_csrftoken':
|
|
csrf_token = c['value']
|
|
if csrf_token:
|
|
session.headers['X-CSRFToken'] = csrf_token
|
|
|
|
# 获取资产
|
|
resp = session.get(f"{url}/api/v1/perms/users/assets/", params={"offset": 0, "limit": 100})
|
|
assets = resp.json() if resp.status_code == 200 else []
|
|
if isinstance(assets, dict): assets = assets.get("results", [])
|
|
target_asset = next((a for a in assets if "hz-oa-ai-g-dataquery" in a.get("hostname", "").lower()), None)
|
|
if not target_asset:
|
|
print("❌ 未找到目标资产")
|
|
return []
|
|
|
|
# 获取系统用户
|
|
resp = session.get(f"{url}/api/v1/perms/users/assets/{target_asset['id']}/system-users/")
|
|
system_users = resp.json() if resp.status_code == 200 else []
|
|
if isinstance(system_users, dict): system_users = system_users.get("results", [])
|
|
target_su = next((su for su in system_users if "admin" in su.get("name", "").lower()), None)
|
|
if not target_su and system_users: target_su = system_users[0]
|
|
if not target_su:
|
|
print("❌ 未找到系统用户")
|
|
return []
|
|
|
|
# 创建 count 个 token
|
|
tokens = []
|
|
for i in range(count):
|
|
resp = session.post(
|
|
f"{url}/api/v1/authentication/connection-token/",
|
|
json={"asset": target_asset['id'], "system_user": target_su['id'], "connect_method": "ssh_client"}
|
|
)
|
|
if resp.status_code == 201:
|
|
td = resp.json()
|
|
tokens.append((td['id'], td['secret']))
|
|
return tokens
|
|
|
|
|
|
# ============================================================
|
|
# PlinkSession — plink PTY 会话管理器
|
|
# ============================================================
|
|
|
|
class PlinkSession:
|
|
"""
|
|
plink PTY 会话管理器
|
|
|
|
一次启动 plink, 在同一个会话里执行多个命令。
|
|
"""
|
|
|
|
def __init__(self, ssh_user, ssh_password, connect_timeout=30, verbose=True):
|
|
self.ssh_user = ssh_user
|
|
self.ssh_password = ssh_password
|
|
self.connect_timeout = connect_timeout
|
|
self.verbose = verbose
|
|
self.proc = None
|
|
self.stdout_chunks = []
|
|
self.stderr_chunks = []
|
|
self.stop_reading = threading.Event()
|
|
self._stdout_thread = None
|
|
self._stderr_thread = None
|
|
self._ready = False
|
|
|
|
def connect(self):
|
|
"""启动 plink, 等待 shell prompt 就绪"""
|
|
if self.verbose:
|
|
print(f" 🚀 启动 plink PTY 会话...")
|
|
|
|
self.proc = subprocess.Popen(
|
|
[PLINK_EXE, '-ssh', '-P', SSH_PORT, '-t', '-pw', self.ssh_password, f'{self.ssh_user}@{SSH_GATEWAY}'],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
bufsize=0,
|
|
)
|
|
|
|
# 后台线程逐块读取
|
|
def reader(stream, buf_list):
|
|
fd = stream.fileno()
|
|
while not self.stop_reading.is_set():
|
|
try:
|
|
chunk = os.read(fd, 4096)
|
|
except (OSError, ValueError):
|
|
break
|
|
if not chunk:
|
|
break
|
|
buf_list.append(chunk)
|
|
|
|
self._stdout_thread = threading.Thread(target=reader, args=(self.proc.stdout, self.stdout_chunks), daemon=True)
|
|
self._stderr_thread = threading.Thread(target=reader, args=(self.proc.stderr, self.stderr_chunks), daemon=True)
|
|
self._stdout_thread.start()
|
|
self._stderr_thread.start()
|
|
|
|
# 等待 shell prompt 就绪
|
|
start = time.time()
|
|
while time.time() - start < self.connect_timeout:
|
|
clean = strip_ansi(self._get_stdout())
|
|
stderr_clean = strip_ansi(self._get_stderr())
|
|
combined = clean + '\n' + stderr_clean
|
|
|
|
if 'Press Return to begin session' in combined:
|
|
self._send('\r\n')
|
|
time.sleep(0.5)
|
|
elif PROMPT_RE.search(clean):
|
|
# 设置终端宽度, 防止长行折行
|
|
self._send('stty cols 1000 2>/dev/null\r\n')
|
|
time.sleep(0.5)
|
|
self.stdout_chunks.clear()
|
|
self._ready = True
|
|
if self.verbose:
|
|
print(f" ✅ 会话就绪 ({time.time()-start:.1f}s)")
|
|
return True
|
|
|
|
time.sleep(0.3)
|
|
|
|
if self.verbose:
|
|
print(f" ❌ 会话超时 ({self.connect_timeout}s)")
|
|
return False
|
|
|
|
def _get_stdout(self):
|
|
return b''.join(self.stdout_chunks).decode('utf-8', errors='replace')
|
|
|
|
def _get_stderr(self):
|
|
return b''.join(self.stderr_chunks).decode('utf-8', errors='replace')
|
|
|
|
def _send(self, text):
|
|
if self.proc and self.proc.stdin:
|
|
self.proc.stdin.write(text.encode('utf-8'))
|
|
self.proc.stdin.flush()
|
|
|
|
def run_command(self, command, timeout=15):
|
|
"""
|
|
在当前会话中执行一个命令
|
|
|
|
返回: {"success": bool, "output": str, "elapsed": float, "timed_out": bool}
|
|
"""
|
|
if not self._ready:
|
|
return {"success": False, "output": "", "elapsed": 0, "timed_out": False, "error": "session not ready"}
|
|
|
|
offset = len(self._get_stdout())
|
|
cmd_start = time.time()
|
|
|
|
if self.verbose:
|
|
print(f" 💻 [{time.strftime('%H:%M:%S')}] 发送: {command[:80]}")
|
|
self._send(f'{command}\r\n')
|
|
|
|
# 轮询检测 prompt 重新出现
|
|
while time.time() - cmd_start < timeout:
|
|
new_stdout = self._get_stdout()[offset:]
|
|
new_clean = strip_ansi(new_stdout)
|
|
|
|
lines = new_clean.split('\n')
|
|
for line in lines[-4:]:
|
|
stripped = line.strip()
|
|
if command in stripped:
|
|
continue
|
|
if PROMPT_RE.search(stripped):
|
|
elapsed = time.time() - cmd_start
|
|
# 提取命令输出
|
|
output_lines = []
|
|
found_echo = False
|
|
for l in lines:
|
|
s = l.strip()
|
|
if not found_echo:
|
|
if command in s:
|
|
found_echo = True
|
|
continue
|
|
else:
|
|
if PROMPT_RE.search(s):
|
|
break
|
|
if s:
|
|
output_lines.append(s)
|
|
|
|
if not output_lines and not found_echo:
|
|
for i, l in enumerate(lines):
|
|
s = l.strip()
|
|
if command in s:
|
|
for j in range(i + 1, len(lines)):
|
|
s2 = lines[j].strip()
|
|
if PROMPT_RE.search(s2):
|
|
break
|
|
if s2:
|
|
output_lines.append(s2)
|
|
break
|
|
|
|
return {
|
|
"success": True,
|
|
"output": '\n'.join(output_lines),
|
|
"elapsed": elapsed,
|
|
"timed_out": False,
|
|
}
|
|
|
|
time.sleep(0.3)
|
|
|
|
# 超时
|
|
elapsed = time.time() - cmd_start
|
|
new_stdout = self._get_stdout()[offset:]
|
|
if self.verbose:
|
|
print(f" ⏰ 超时 ({elapsed:.1f}s)")
|
|
return {
|
|
"success": False,
|
|
"output": strip_ansi(new_stdout).strip(),
|
|
"elapsed": elapsed,
|
|
"timed_out": True,
|
|
}
|
|
|
|
def close(self):
|
|
"""发送 exit, 关闭会话"""
|
|
if self._ready and self.proc:
|
|
try:
|
|
self._send('exit\r\n')
|
|
time.sleep(0.5)
|
|
except: pass
|
|
|
|
self.stop_reading.set()
|
|
if self._stdout_thread:
|
|
self._stdout_thread.join(timeout=2)
|
|
if self._stderr_thread:
|
|
self._stderr_thread.join(timeout=2)
|
|
|
|
if self.proc:
|
|
try: self.proc.stdin.close()
|
|
except: pass
|
|
try: self.proc.wait(timeout=3)
|
|
except:
|
|
try: self.proc.kill(); self.proc.wait(timeout=2)
|
|
except: pass
|
|
|
|
|
|
# ============================================================
|
|
# 命令执行: exec / batch
|
|
# ============================================================
|
|
|
|
def cmd_exec(commands, cmd_timeout=15, parallel=False):
|
|
"""执行命令 (单条或多条, 串行或并行)"""
|
|
os.makedirs(str(OUTPUT_DIR), exist_ok=True)
|
|
total_start = time.time()
|
|
results = []
|
|
|
|
if not parallel:
|
|
# 串行: 一个 token + 一个会话
|
|
print(f"模式: 串行 ({len(commands)} 命令, 超时 {cmd_timeout}s/命令)")
|
|
tokens = get_connection_tokens(1)
|
|
if not tokens:
|
|
print("❌ 获取 token 失败")
|
|
return []
|
|
|
|
token_id, token_secret = tokens[0]
|
|
session = PlinkSession(f"JMS-{token_id}", token_secret)
|
|
if not session.connect():
|
|
print("❌ 会话启动失败")
|
|
return [{"command": c, "success": False, "output": "", "elapsed": 0, "timed_out": False} for c in commands]
|
|
|
|
for i, cmd in enumerate(commands, 1):
|
|
print(f"\n--- [{i}/{len(commands)}] ---")
|
|
result = session.run_command(cmd, timeout=cmd_timeout)
|
|
result["command"] = cmd
|
|
result["index"] = i
|
|
status = "✅" if result["success"] else ("⏰" if result.get("timed_out") else "❌")
|
|
print(f" {status} 完成 ({result['elapsed']:.1f}s)")
|
|
if result["output"]:
|
|
for line in result["output"].split("\n"):
|
|
if line.strip():
|
|
print(f" | {line.strip()[:120]}")
|
|
results.append(result)
|
|
|
|
session.close()
|
|
|
|
else:
|
|
# 并行: N 个 token + N 个会话
|
|
print(f"模式: 并行 ({len(commands)} 命令, 超时 {cmd_timeout}s/命令)")
|
|
tokens = get_connection_tokens(len(commands))
|
|
if len(tokens) < len(commands):
|
|
print(f"⚠️ 只获取到 {len(tokens)}/{len(commands)} 个 token, 降级为串行")
|
|
return cmd_exec(commands, cmd_timeout=cmd_timeout, parallel=False)
|
|
|
|
def execute_single(idx, cmd, tid, tsec):
|
|
session = PlinkSession(f"JMS-{tid}", tsec, verbose=False)
|
|
if not session.connect():
|
|
return {"command": cmd, "success": False, "output": "", "elapsed": 0, "timed_out": False, "index": idx}
|
|
result = session.run_command(cmd, timeout=cmd_timeout)
|
|
result["command"] = cmd
|
|
result["index"] = idx
|
|
session.close()
|
|
return result
|
|
|
|
threads = []
|
|
thread_results = [None] * len(commands)
|
|
|
|
def worker(idx, cmd, tid, tsec):
|
|
thread_results[idx] = execute_single(idx, cmd, tid, tsec)
|
|
|
|
for i, cmd in enumerate(commands):
|
|
tid, tsec = tokens[i]
|
|
t = threading.Thread(target=worker, args=(i, cmd, tid, tsec))
|
|
threads.append(t)
|
|
t.start()
|
|
|
|
for t in threads:
|
|
t.join(timeout=cmd_timeout + 60)
|
|
|
|
results = [r for r in thread_results if r is not None]
|
|
results.sort(key=lambda x: x.get("index", 0))
|
|
|
|
for r in results:
|
|
status = "✅" if r.get("success") else "❌"
|
|
print(f" [{r.get('index')}] {status} {r.get('elapsed', 0):.1f}s — {r.get('command', '')[:50]}")
|
|
|
|
total_elapsed = time.time() - total_start
|
|
success_count = sum(1 for r in results if r.get("success"))
|
|
|
|
print(f"\n{'=' * 50}")
|
|
print(f"汇总: {success_count}/{len(commands)} 成功, 总耗时 {total_elapsed:.1f}s")
|
|
print(f"{'=' * 50}")
|
|
|
|
# 保存报告
|
|
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
report_path = OUTPUT_DIR / f"exec_{timestamp}.md"
|
|
lines = [
|
|
f"# JumpServer 执行报告",
|
|
f"",
|
|
f"**时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
|
f"**模式**: {'并行' if parallel else '串行'}",
|
|
f"**命令数**: {len(commands)}",
|
|
f"**成功**: {success_count}/{len(commands)}",
|
|
f"**总耗时**: {total_elapsed:.1f}s",
|
|
f"",
|
|
]
|
|
for r in results:
|
|
status = "✅" if r.get("success") else "❌"
|
|
lines.append(f"## [{r.get('index', '?')}] {status} {r.get('elapsed', 0):.1f}s — {r.get('command', '')[:60]}")
|
|
lines.append(f"")
|
|
lines.append(f"```")
|
|
lines.append(r.get("output", "(空)"))
|
|
lines.append(f"```")
|
|
lines.append(f"")
|
|
report_path.write_text("\n".join(lines), encoding="utf-8")
|
|
print(f"💾 报告: {report_path}")
|
|
|
|
return results
|
|
|
|
|
|
# ============================================================
|
|
# 文件传输: upload / download (base64 通道)
|
|
# ============================================================
|
|
|
|
def cmd_upload(local_path, remote_path, cmd_timeout=60):
|
|
"""
|
|
上传本地文件到远程 (base64 编码, 分块发送)
|
|
|
|
适用: 小文件 (配置/脚本/文本, <100KB)
|
|
大文件建议用 elFinder Web UI
|
|
"""
|
|
local_file = Path(local_path)
|
|
if not local_file.exists():
|
|
print(f"❌ 本地文件不存在: {local_path}")
|
|
return False
|
|
|
|
local_data = local_file.read_bytes()
|
|
local_md5 = hashlib.md5(local_data).hexdigest()
|
|
b64_data = base64.b64encode(local_data).decode('ascii')
|
|
|
|
print(f"📤 上传: {local_path} → {remote_path}")
|
|
print(f" 原始: {len(local_data)} bytes, base64: {len(b64_data)} chars, MD5: {local_md5[:12]}...")
|
|
|
|
# 分块 (每块 500 chars, PTY 宽度 1000 留余量)
|
|
CHUNK_SIZE = 500
|
|
chunks = [b64_data[i:i+CHUNK_SIZE] for i in range(0, len(b64_data), CHUNK_SIZE)]
|
|
print(f" 分 {len(chunks)} 块发送")
|
|
|
|
# 获取 token + 启动会话
|
|
tokens = get_connection_tokens(1)
|
|
if not tokens:
|
|
print("❌ 获取 token 失败")
|
|
return False
|
|
|
|
token_id, token_secret = tokens[0]
|
|
session = PlinkSession(f"JMS-{token_id}", token_secret)
|
|
if not session.connect():
|
|
print("❌ 会话启动失败")
|
|
return False
|
|
|
|
try:
|
|
# 1. 清空目标文件
|
|
session.run_command(f'> {remote_path}', timeout=5)
|
|
|
|
# 2. 逐块追加
|
|
for i, chunk in enumerate(chunks):
|
|
cmd = f"echo '{chunk}' | base64 -d >> {remote_path}"
|
|
r = session.run_command(cmd, timeout=10)
|
|
if not r["success"]:
|
|
print(f" ❌ 块 {i+1}/{len(chunks)} 发送失败")
|
|
return False
|
|
if (i+1) % 20 == 0 or (i+1) == len(chunks):
|
|
print(f" 📦 已发送 {i+1}/{len(chunks)} 块")
|
|
|
|
# 3. 验证大小
|
|
r = session.run_command(f'wc -c < {remote_path}', timeout=5)
|
|
if r["success"]:
|
|
remote_size = int(r["output"].strip()) if r["output"].strip().isdigit() else -1
|
|
if remote_size == len(local_data):
|
|
print(f" ✅ 上传成功! 大小匹配 ({remote_size} bytes)")
|
|
|
|
# 4. MD5 验证 (可选, 文本文件可能因换行符不匹配)
|
|
r2 = session.run_command(f'md5sum {remote_path}', timeout=5)
|
|
if r2["success"]:
|
|
remote_md5 = r2["output"].split()[0]
|
|
if remote_md5 == local_md5:
|
|
print(f" ✅ MD5 匹配! 文件完整")
|
|
else:
|
|
print(f" ⚠️ MD5 不匹配 (本地 {local_md5[:12]}, 远程 {remote_md5[:12]})")
|
|
print(f" 可能原因: PTY 换行符转换, 文本内容应可读")
|
|
return True
|
|
else:
|
|
print(f" ❌ 大小不匹配 (本地 {len(local_data)}, 远程 {remote_size})")
|
|
return False
|
|
else:
|
|
print(f" ⚠️ 无法验证远程文件大小")
|
|
return True
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def cmd_download(remote_path, local_path, cmd_timeout=30):
|
|
"""
|
|
下载远程文件到本地 (base64 编码, marker 提取)
|
|
|
|
适用: 小文件
|
|
"""
|
|
print(f"📥 下载: {remote_path} → {local_path}")
|
|
|
|
# 获取 token + 启动会话
|
|
tokens = get_connection_tokens(1)
|
|
if not tokens:
|
|
print("❌ 获取 token 失败")
|
|
return False
|
|
|
|
token_id, token_secret = tokens[0]
|
|
session = PlinkSession(f"JMS-{token_id}", token_secret)
|
|
if not session.connect():
|
|
print("❌ 会话启动失败")
|
|
return False
|
|
|
|
try:
|
|
# 用 marker 包围 base64 输出, 精确提取
|
|
cmd = f'echo "<<<B64_START>>>" && base64 {remote_path} && echo "<<<B64_END>>>"'
|
|
result = session.run_command(cmd, timeout=cmd_timeout)
|
|
|
|
if not result["success"]:
|
|
print(f"❌ 命令失败: {result.get('output', '')[:200]}")
|
|
return False
|
|
|
|
output = result["output"]
|
|
if '<<<B64_START>>>' in output and '<<<B64_END>>>' in output:
|
|
b64_part = output.split('<<<B64_START>>>', 1)[1]
|
|
b64_part = b64_part.split('<<<B64_END>>>', 1)[0]
|
|
b64_clean = ''.join(b64_part.split())
|
|
|
|
try:
|
|
file_data = base64.b64decode(b64_clean)
|
|
local_file = Path(local_path)
|
|
local_file.write_bytes(file_data)
|
|
local_md5 = hashlib.md5(file_data).hexdigest()
|
|
print(f" ✅ 下载成功! {len(file_data)} bytes, MD5: {local_md5[:12]}...")
|
|
print(f" 💾 已保存: {local_path}")
|
|
return True
|
|
except Exception as e:
|
|
print(f" ❌ base64 解码失败: {e}")
|
|
return False
|
|
else:
|
|
print(f" ❌ 未找到 marker (文件可能不存在)")
|
|
print(f" 输出: {output[:200]}")
|
|
return False
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
# ============================================================
|
|
# CLI
|
|
# ============================================================
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="JumpServer 运维工具集 — 远程命令执行 + 文件传输",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
示例:
|
|
# 远程命令执行
|
|
%(prog)s exec -c "hostname"
|
|
%(prog)s exec -c "hostname" -c "uptime" -c "docker ps"
|
|
%(prog)s exec -c "hostname" -c "uptime" --parallel
|
|
|
|
# 批量命令 (从文件)
|
|
%(prog)s batch -f commands.txt
|
|
|
|
# 文件传输
|
|
%(prog)s upload local_config.conf /tmp/remote_config.conf
|
|
%(prog)s download /etc/nginx/nginx.conf ./nginx.conf.bak
|
|
""",
|
|
)
|
|
subparsers = parser.add_subparsers(dest="subcommand", help="子命令")
|
|
|
|
# exec
|
|
p_exec = subparsers.add_parser("exec", help="远程命令执行 (单条/多条/并行)")
|
|
p_exec.add_argument("-c", "--command", action="append", required=True, help="远程命令 (可多次指定)")
|
|
p_exec.add_argument("--cmd-timeout", type=int, default=15, help="每命令超时秒数 (默认 15)")
|
|
p_exec.add_argument("--parallel", action="store_true", help="并行模式 (每命令独立 token+会话)")
|
|
|
|
# batch
|
|
p_batch = subparsers.add_parser("batch", help="批量命令 (从文件读取)")
|
|
p_batch.add_argument("-f", "--file", required=True, help="命令文件 (每行一个, # 开头为注释)")
|
|
p_batch.add_argument("--cmd-timeout", type=int, default=15, help="每命令超时秒数")
|
|
p_batch.add_argument("--parallel", action="store_true", help="并行模式")
|
|
|
|
# upload
|
|
p_upload = subparsers.add_parser("upload", help="文件上传 (base64 通道, 小文件)")
|
|
p_upload.add_argument("local", help="本地文件路径")
|
|
p_upload.add_argument("remote", help="远程文件路径")
|
|
|
|
# download
|
|
p_download = subparsers.add_parser("download", help="文件下载 (base64 通道, 小文件)")
|
|
p_download.add_argument("remote", help="远程文件路径")
|
|
p_download.add_argument("local", help="本地保存路径")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.subcommand == "exec":
|
|
results = cmd_exec(args.command, cmd_timeout=args.cmd_timeout, parallel=args.parallel)
|
|
success = all(r.get("success") for r in results) if results else False
|
|
sys.exit(0 if success else 1)
|
|
|
|
elif args.subcommand == "batch":
|
|
commands = []
|
|
for line in Path(args.file).read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if line and not line.startswith("#"):
|
|
commands.append(line)
|
|
if not commands:
|
|
print("❌ 命令文件为空")
|
|
sys.exit(1)
|
|
results = cmd_exec(commands, cmd_timeout=args.cmd_timeout, parallel=args.parallel)
|
|
success = all(r.get("success") for r in results) if results else False
|
|
sys.exit(0 if success else 1)
|
|
|
|
elif args.subcommand == "upload":
|
|
success = cmd_upload(args.local, args.remote)
|
|
sys.exit(0 if success else 1)
|
|
|
|
elif args.subcommand == "download":
|
|
success = cmd_download(args.remote, args.local)
|
|
sys.exit(0 if success else 1)
|
|
|
|
else:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|