""" 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 "<<>>" && base64 {remote_path} && echo "<<>>"' result = session.run_command(cmd, timeout=cmd_timeout) if not result["success"]: print(f"❌ 命令失败: {result.get('output', '')[:200]}") return False output = result["output"] if '<<>>' in output and '<<>>' in output: b64_part = output.split('<<>>', 1)[1] b64_part = b64_part.split('<<>>', 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()