diff --git a/scripts/local-gatekeeper.py b/scripts/local-gatekeeper.py new file mode 100644 index 0000000..90e3de7 --- /dev/null +++ b/scripts/local-gatekeeper.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +""" +光湖本地中继引擎 · Local Gatekeeper v1.0 +监听 localhost:3912,WorkBuddy 通过它操作六台服务器。 +本地引擎负责:密钥管理、代理绕过、分chunk传输、命令路由。 + +用法: + python3 local-gatekeeper.py # 启动 HTTP 服务 + python3 local-gatekeeper.py --kill # 停止服务 + python3 local-gatekeeper.py --status # 查看状态 +""" + +import argparse +import base64 +import json +import os +import signal +import subprocess +import sys +import time +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path + +PID_FILE = os.path.expanduser("~/.workbuddy/local-gatekeeper.pid") +CONFIG_FILE = os.path.expanduser("~/.workbuddy/local-gatekeeper-config.json") + +# ============================================================ +# 服务器配置 — 从 gatekeeper-deployment.json 同步 +# ============================================================ +SERVERS = { + "BS-GZ-006": { + "name": "广州 · 代码仓库", + "ip": "43.139.217.141", + "port": 3910, + "key": "zy_gtw_4a155446b7acc09fe46a2a29972c0ca5d9954da19c35dc23", + "repo": "/opt/guanghulab-repo", + }, + "BS-SG-001": { + "name": "新加坡 · 铸渊大脑", + "ip": "43.156.237.110", + "port": 3911, + "key": "zy_gtw_c38be341d65043eee0ba51a214a267832a9ae46dd73e423c", + "repo": "/opt/zhuyuan/guanghulab", + "version": "2.0.0", + }, + "BS-SG-002": { + "name": "新加坡 · 面孔", + "ip": "43.134.16.246", + "port": 3910, + "key": "zy_gtw_ceb06d8a70d0cd38678239b3a1a9b2ba5cba77a3fbe5ebbf", + "version": "2.0.0", + }, + "BS-SG-003": { + "name": "新加坡 · 中继", + "ip": "43.153.193.169", + "port": 3910, + "key": "zy_gtw_d7c033d8ae992ffc9dd3e0dd5fe332dace0b644c0ae7c847", + }, + "ZY-SG-006": { + "name": "新加坡 · 语料", + "ip": "43.153.203.105", + "port": 3910, + "key": "zy_gtw_fff861a2fe40cbdc366ee21f218023be8b1c182f66c852d3", + }, + "BS-SH-005": { + "name": "上海 · 国内节点", + "ip": "124.223.10.33", + "port": 3910, + "key": "zy_gtw_b1045de5ddfd7832e3c53349d9c896f054b85a4a9ece22f9", + }, +} + +CHUNK_SIZE = 6000 +CMD_TIMEOUT = 30 + +# ============================================================ +# 核心:直连 Gatekeeper 执行命令(绕过代理) +# ============================================================ +def gk_exec(server_code: str, cmd: str, timeout: int = None) -> dict: + """通过指定服务器的 Gatekeeper 执行命令""" + if server_code not in SERVERS: + return {"ok": False, "error": f"未知服务器: {server_code}"} + + srv = SERVERS[server_code] + url = f"http://{srv['ip']}:{srv['port']}/exec" + t = timeout or CMD_TIMEOUT + + env = os.environ.copy() + for k in ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"]: + env.pop(k, None) + + try: + result = subprocess.run( + ["curl", "-s", "--connect-timeout", str(t), "--max-time", str(t), + "--noproxy", "*", + "-X", "POST", url, + "-H", "Content-Type: application/json", + "-H", f"Authorization: Bearer {srv['key']}", + "-d", json.dumps({"cmd": cmd})], + capture_output=True, text=True, timeout=t + 5, env=env + ) + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return {"ok": False, "error": f"非JSON响应", "raw": result.stdout[:500], "stderr": result.stderr} + except subprocess.TimeoutExpired: + return {"ok": False, "error": f"超时 ({t}s)"} + except Exception as e: + return {"ok": False, "error": str(e)} + + +def gk_health(server_code: str) -> dict: + """健康检查""" + if server_code not in SERVERS: + return {"ok": False, "error": f"未知服务器: {server_code}"} + srv = SERVERS[server_code] + url = f"http://{srv['ip']}:{srv['port']}/health" + + env = os.environ.copy() + for k in ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"]: + env.pop(k, None) + + try: + result = subprocess.run( + ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", + "--connect-timeout", "5", "--noproxy", "*", url], + capture_output=True, text=True, timeout=10, env=env + ) + code = result.stdout.strip() + return {"ok": True, "http_code": code, "reachable": code in ("200", "404", "405")} + except Exception as e: + return {"ok": False, "error": str(e), "reachable": False} + + +def sync_file(server_code: str, local_path: str, target_path: str, commit_msg: str) -> dict: + """同步文件到指定服务器仓库""" + if server_code not in SERVERS: + return {"ok": False, "error": f"未知服务器: {server_code}"} + srv = SERVERS[server_code] + if "repo" not in srv: + return {"ok": False, "error": f"服务器 {server_code} 未配置仓库路径"} + + # 读本地文件 + if not os.path.exists(local_path): + return {"ok": False, "error": f"本地文件不存在: {local_path}"} + + with open(local_path, "rb") as f: + content = f.read() + expected_bytes = len(content) + b64 = base64.b64encode(content).decode("ascii") + + # 清临时文件 + gk_exec(server_code, "rm -f /tmp/lgk_chunk_* /tmp/lgk_b64.txt") + + # 分 chunk + chunks = [b64[i:i + CHUNK_SIZE] for i in range(0, len(b64), CHUNK_SIZE)] + for i, chunk in enumerate(chunks): + chunk_b64 = base64.b64encode(chunk.encode()).decode() + cmd = f'echo "{chunk_b64}" | base64 -d >> /tmp/lgk_b64.txt' + r = gk_exec(server_code, cmd) + if not r.get("ok"): + return {"ok": False, "error": f"chunk {i + 1}/{len(chunks)} 上传失败", "detail": r} + + # 解码写入 + full_target = f"{srv['repo']}/{target_path}" + target_dir = os.path.dirname(full_target) + write_cmd = ( + f"mkdir -p {target_dir} && " + f"base64 -d /tmp/lgk_b64.txt > {full_target} && " + f"echo BYTES=$(wc -c < {full_target})" + ) + r = gk_exec(server_code, write_cmd) + if not r.get("ok"): + return {"ok": False, "error": "写入失败", "detail": r} + + stdout = r.get("stdout", "") + if f"BYTES={expected_bytes}" not in stdout: + return {"ok": False, "error": f"字节校验失败: 期望={expected_bytes}", "detail": r} + + # Git 提交 + escaped_msg = commit_msg.replace('"', '\\"') + git_cmd = ( + f"cd {srv['repo']} && " + f"git config user.email zhuyuan@guanghulab.com 2>/dev/null; " + f"git config user.name '铸渊 ICE-GL-ZY001' 2>/dev/null; " + f"git pull --rebase origin main 2>&1; " + f"git add {target_path} && " + f"GIT_OUT=$(git commit -m \"{escaped_msg}\" 2>&1); " + f"echo \"COMMIT_DONE\"; " + f"if echo \"$GIT_OUT\" | grep -q 'nothing to commit'; then echo 'NO_CHANGE'; " + f"else git push origin main 2>&1; fi" + ) + r = gk_exec(server_code, git_cmd, timeout=60) + stdout = r.get("stdout", "") + + gk_exec(server_code, "rm -f /tmp/lgk_chunk_* /tmp/lgk_b64.txt") + + return { + "ok": True, + "server": server_code, + "target": target_path, + "bytes": expected_bytes, + "chunks": len(chunks), + "committed": "NO_CHANGE" not in stdout, + } + + +# ============================================================ +# HTTP 服务 +# ============================================================ +class GatekeeperHandler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass # 安静模式 + + def _send_json(self, data, status=200): + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps(data, ensure_ascii=False).encode()) + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def do_GET(self): + if self.path == "/health": + self._send_json({"ok": True, "service": "local-gatekeeper", "version": "1.0"}) + elif self.path == "/servers": + result = {} + for code, srv in SERVERS.items(): + h = gk_health(code) + result[code] = {"name": srv["name"], "reachable": h.get("reachable", False)} + self._send_json({"ok": True, "servers": result}) + else: + self._send_json({"ok": False, "error": "未知端点"}, 404) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length).decode() if length else "{}" + try: + data = json.loads(body) + except json.JSONDecodeError: + self._send_json({"ok": False, "error": "JSON 解析失败"}, 400) + return + + if self.path == "/exec": + server = data.get("server", "BS-SG-001") + cmd = data.get("cmd", "") + timeout = data.get("timeout") + if not cmd: + self._send_json({"ok": False, "error": "缺少 cmd 参数"}, 400) + return + result = gk_exec(server, cmd, timeout) + self._send_json(result) + + elif self.path == "/sync": + server = data.get("server", "BS-SG-001") + local = data.get("file", "") + target = data.get("target", "") + msg = data.get("msg", "sync: local-gatekeeper") + if not local or not target: + self._send_json({"ok": False, "error": "缺少 file/target 参数"}, 400) + return + result = sync_file(server, local, target, msg) + self._send_json(result) + + elif self.path == "/health-all": + results = {} + for code in SERVERS: + h = gk_health(code) + results[code] = h + self._send_json({"ok": True, "servers": results}) + + else: + self._send_json({"ok": False, "error": "未知端点"}, 404) + + +# ============================================================ +# 进程管理 +# ============================================================ +def is_running(): + if not os.path.exists(PID_FILE): + return False + try: + with open(PID_FILE) as f: + pid = int(f.read().strip()) + os.kill(pid, 0) + return True + except (OSError, ValueError): + return False + + +def start(): + if is_running(): + print("本地中继引擎已在运行中") + # 显示状态 + r = subprocess.run(["curl", "-s", "http://localhost:3912/health"], capture_output=True, text=True) + print(r.stdout) + return + + server = HTTPServer(("127.0.0.1", 3912), GatekeeperHandler) + pid = os.fork() + if pid == 0: + # 子进程 + os.setsid() + with open(PID_FILE, "w") as f: + f.write(str(os.getpid())) + print(f"光湖本地中继引擎已启动 · localhost:3912 · PID {os.getpid()}") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + else: + print(f"光湖本地中继引擎已启动 · localhost:3912 · PID {pid}") + print(f" /exec → 执行远程命令") + print(f" /sync → 同步文件到服务器") + print(f" /servers → 查看服务器状态") + print(f" /health → 引擎自检") + + +def stop(): + if not is_running(): + print("本地中继引擎未运行") + return + with open(PID_FILE) as f: + pid = int(f.read().strip()) + os.kill(pid, signal.SIGTERM) + os.remove(PID_FILE) + print(f"本地中继引擎已停止 · PID {pid}") + + +def status(): + if not is_running(): + print("本地中继引擎: ❌ 未运行") + return + print("本地中继引擎: ✅ 运行中 · localhost:3912") + r = subprocess.run(["curl", "-s", "http://localhost:3912/servers"], capture_output=True, text=True) + try: + data = json.loads(r.stdout) + print("\n服务器状态:") + for code, srv in data.get("servers", {}).items(): + icon = "🟢" if srv["reachable"] else "🔴" + print(f" {icon} {code} · {srv['name']}") + except: + print(r.stdout) + + +# ============================================================ +# 命令行入口 +# ============================================================ +def main(): + parser = argparse.ArgumentParser(description="光湖本地中继引擎") + parser.add_argument("--start", action="store_true", help="启动引擎") + parser.add_argument("--stop", "--kill", action="store_true", help="停止引擎") + parser.add_argument("--status", action="store_true", help="查看状态") + parser.add_argument("--restart", action="store_true", help="重启引擎") + args = parser.parse_args() + + # 默认启动 + if not any([args.stop, args.status, args.restart, args.start]): + args.start = True + + if args.stop or args.restart: + stop() + time.sleep(0.5) + + if args.start or args.restart: + start() + + if args.status: + status() + + +if __name__ == "__main__": + main()