Files

699 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"))
_SKILL_DIR = Path(os.environ.get("JP_SKILL_DIR", _WORKBUDDY_DIR / "skills" / "jumpserver-automation-shareable"))
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()