#!/usr/bin/env python3 """Manage JSRPC server and Flask proxy lifecycle. Supports the legacy flag interface and the state-file subcommand interface. Includes PID identity verification to avoid killing wrong processes. Usage (legacy flag interface): python3 scripts/manage_services.py --service jsrpc --analysis analysis_result.json --output artifacts/jsrpc_status.json --action start --force python3 scripts/manage_services.py --service flask --analysis analysis_result.json --flask-file generated/flask_proxy.py --output artifacts/flask_status.json --action start --force python3 scripts/manage_services.py --service jsrpc --analysis analysis_result.json --output artifacts/jsrpc_status.json --action stop Usage (state-file subcommand): python3 scripts/manage_services.py start --kind jsrpc --state artifacts/jsrpc_state.json python3 scripts/manage_services.py stop --state artifacts/jsrpc_state.json """ from __future__ import annotations import argparse import json import os import signal import socket import subprocess import sys import time from pathlib import Path from shutil import which from common import dump_json, load_json # Common JSRPC binary names and paths to search JSRPC_SEARCH_NAMES = ["jsrpc", "jsrpc-mac", "jsrpc-linux", "mac_m_arm64", "mac_x64", "linux_amd64"] JSRPC_SEARCH_DIRS = [ Path.home() / "jsrpc", Path.home() / "jstest", Path.home() / "hacker" / "jstest", Path.home() / "tools" / "jsrpc", Path.home() / ".local" / "bin", Path("/usr/local/bin"), Path("/opt/jsrpc"), ] def find_jsrpc_binary() -> str | None: """Auto-discover JSRPC binary from common locations and PATH.""" # 1. Check PATH for name in JSRPC_SEARCH_NAMES: found = which(name) if found: return found # 2. Check common directories for search_dir in JSRPC_SEARCH_DIRS: if not search_dir.exists(): continue for name in JSRPC_SEARCH_NAMES: candidate = search_dir / name if candidate.is_file() and os.access(candidate, os.X_OK): return str(candidate) for subdir in search_dir.iterdir(): if subdir.is_dir(): for name in JSRPC_SEARCH_NAMES: candidate = subdir / name if candidate.is_file() and os.access(candidate, os.X_OK): return str(candidate) # 3. Check current directory and parent cwd = Path.cwd() for name in JSRPC_SEARCH_NAMES: candidate = cwd / name if candidate.is_file() and os.access(candidate, os.X_OK): return str(candidate) candidate = cwd.parent / name if candidate.is_file() and os.access(candidate, os.X_OK): return str(candidate) return None def process_identity(pid: int) -> dict: """Get process identity info for PID verification.""" result = {"pid": pid, "alive": False, "exe": None, "started": None, "command": None} try: os.kill(pid, 0) result["alive"] = True except OSError: return result proc = Path(f"/proc/{pid}") if proc.exists(): try: result["exe"] = str((proc / "exe").resolve()) except OSError: pass try: result["started"] = (proc / "stat").read_text().split()[21] except Exception: pass try: result["command"] = (proc / "cmdline").read_bytes().replace(b"\x00", b" ").decode(errors="replace").strip() except Exception: pass else: try: result["command"] = subprocess.run( ["ps", "-p", str(pid), "-o", "command="], text=True, capture_output=True, timeout=2 ).stdout.strip() or None result["started"] = subprocess.run( ["ps", "-p", str(pid), "-o", "lstart="], text=True, capture_output=True, timeout=2 ).stdout.strip() or None except Exception: pass return result def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool: for family in [socket.AF_INET, socket.AF_INET6]: try: with socket.socket(family, socket.SOCK_STREAM) as s: s.settimeout(1) h = "127.0.0.1" if family == socket.AF_INET else "::1" if s.connect_ex((h, port)) == 0: return True except Exception: pass return False def get_pid_on_port(port: int) -> int | None: try: result = subprocess.run(["lsof", "-i", f":{port}", "-t"], capture_output=True, text=True, timeout=5) for pid in result.stdout.strip().split("\n"): pid = pid.strip() if pid.isdigit(): return int(pid) except Exception: pass return None def find_available_port(start: int, host: str = "127.0.0.1", attempts: int = 30) -> int | None: """Find an available local port without touching an unrelated process.""" for candidate in range(int(start), int(start) + attempts): if not is_port_in_use(candidate, host): return candidate return None def kill_process(pid: int) -> bool: try: os.kill(pid, signal.SIGTERM) time.sleep(1) try: os.kill(pid, 0) os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass return True except ProcessLookupError: return True except Exception: return False def check_health(host: str, port: int) -> bool: try: import urllib.request req = urllib.request.urlopen(f"http://{host}:{port}/healthz", timeout=3) return req.status == 200 except Exception: return False # === Legacy flag interface === def legacy_jsrpc_start(binary_path: str, port: int) -> dict: proc = subprocess.Popen([binary_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) for _ in range(10): time.sleep(0.5) if is_port_in_use(port): identity = process_identity(proc.pid) return {"status": "started", "pid": proc.pid, "binary_path": str(Path(binary_path).resolve()), "port": port, "started": identity.get("started"), "observed_command": identity.get("command"), "stop_hint": "使用同一状态文件执行 manage_services.py --action stop"} return {"status": "start_failed", "pid": proc.pid, "binary_path": binary_path, "port": port} def legacy_flask_start(flask_file: str, host: str, port: int, route: str = "/autodecoder") -> dict: requested_port = int(port) actual_port = requested_port if is_port_in_use(actual_port, host): actual_port = find_available_port(actual_port + 1, host) if actual_port is None: existing_pid = get_pid_on_port(requested_port) return {"status": "port_occupied", "port": requested_port, "existing_pid": existing_pid, "hint": f"端口 {requested_port} 已被占用,且没有找到可用备用端口"} if not os.path.isfile(flask_file): return {"status": "file_not_found", "flask_file": flask_file, "hint": "Flask 代理文件不存在,先运行代码生成"} log_file = Path(flask_file).parent / "flask_proxy.log" environment = os.environ.copy() environment["JSRA_FLASK_PORT"] = str(actual_port) with open(log_file, "w") as lf: proc = subprocess.Popen([sys.executable, flask_file], stdout=lf, stderr=subprocess.STDOUT, start_new_session=True, env=environment) for _ in range(10): time.sleep(0.5) if is_port_in_use(actual_port, host): healthy = check_health(host, actual_port) identity = process_identity(proc.pid) return { "status": "started", "pid": proc.pid, "host": host, "port": actual_port, "requested_port": requested_port, "port_fallback": actual_port != requested_port, "flask_file": str(Path(flask_file).resolve()), "started": identity.get("started"), "observed_command": identity.get("command"), "url": f"http://{host}:{actual_port}{route}", "healthz": f"http://{host}:{actual_port}/healthz", "encode_url": f"http://{host}:{actual_port}/encode", "decode_url": f"http://{host}:{actual_port}/decode", "healthy": healthy, "log_file": str(log_file), "stop_hint": "使用同一状态文件执行 manage_services.py --action stop" } return {"status": "start_failed", "pid": proc.pid, "port": actual_port, "requested_port": requested_port, "flask_file": str(Path(flask_file).resolve()), "log_file": str(log_file)} def legacy_status(path: str) -> dict: """Load a previously written legacy status file without guessing a PID.""" try: return load_json(Path(path), {}) except (OSError, ValueError, TypeError): return {} def managed_pid_matches(status: dict, kind: str) -> bool: """Verify that a legacy status PID still belongs to this service. The legacy interface predates state files, so only a recorded PID and a recorded executable/script identity are accepted. A port occupant alone is never sufficient evidence for a kill operation. """ pid = status.get("pid") if not pid: return False try: identity = process_identity(int(pid)) except (TypeError, ValueError): return False if not identity.get("alive"): return False expected = str(status.get("flask_file" if kind == "flask" else "binary_path", "")) command = str(identity.get("command") or "") if not expected or not command: return False if status.get("started") and identity.get("started") and status["started"] != identity["started"]: return False return Path(expected).name in command or expected in command def stop_legacy_service(kind: str, status_path: str, configured_port: int) -> dict: """Stop only a process recorded by this skill's own status artifact.""" status = legacy_status(status_path) if not status: return {"status": "not_running", "port": configured_port, "hint": "没有可验证的服务状态文件,未尝试按端口杀进程"} if not managed_pid_matches(status, kind): return {"status": "identity_mismatch", "pid": status.get("pid"), "port": status.get("port", configured_port), "hint": "状态文件中的进程身份无法验证,未执行停止操作"} pid = int(status["pid"]) if kill_process(pid): return {"status": "stopped", "pid": pid, "port": status.get("port", configured_port)} return {"status": "stop_failed", "pid": pid, "port": status.get("port", configured_port)} def legacy_mode(args: argparse.Namespace) -> dict: """Legacy flag-based interface.""" analysis = load_json(Path(args.analysis)) if args.service == "jsrpc": config = analysis.get("jsrpc_server", {}) port = config.get("port", 12080) if args.action == "stop": return stop_legacy_service("jsrpc", args.output, port) elif args.action == "status": if not is_port_in_use(port): return {"status": "not_running", "port": port} pid = get_pid_on_port(port) return {"status": "already_running", "pid": pid, "port": port, "stop_command": f"python3 scripts/manage_services.py --service jsrpc --analysis {args.analysis} --output {args.output} --action stop" if pid else None} else: # start if is_port_in_use(port): pid = get_pid_on_port(port) status = legacy_status(args.output) if status.get("status") in {"started", "already_running"} and managed_pid_matches(status, "jsrpc"): return {"status": "already_running", "pid": status.get("pid", pid), "port": port, "stop_command": f"python3 scripts/manage_services.py --service jsrpc --analysis {args.analysis} --output {args.output} --action stop"} return {"status": "port_occupied", "pid": pid, "port": port, "hint": f"端口 {port} 已被其他进程占用,未执行强制终止;请释放端口或更换 JSRPC 端口"} binary_path = config.get("binary_path", "") if binary_path == "auto" or not binary_path: binary_path = find_jsrpc_binary() if binary_path and os.path.isfile(binary_path): return legacy_jsrpc_start(binary_path, port) return {"status": "not_found", "port": port, "hint": "请在 analysis_result.json 的 jsrpc_server.binary_path 中提供 JSRPC 服务器二进制路径,或手动启动后重试"} else: # flask config = analysis.get("flask_server", analysis.get("flask", {})) host = config.get("listen_host", config.get("host", "127.0.0.1")) port = config.get("listen_port", config.get("port", 5000)) if args.action == "stop": return stop_legacy_service("flask", args.output, port) elif args.action == "status": if not is_port_in_use(port, host): return {"status": "not_running", "port": port} pid = get_pid_on_port(port) healthy = check_health(host, port) flask_config = analysis.get("flask", {}) route = flask_config.get("route", "/autodecoder") return {"status": "running", "pid": pid, "port": port, "healthy": healthy, "url": f"http://{host}:{port}{route}", "stop_command": f"python3 scripts/manage_services.py --service flask --analysis {args.analysis} --output {args.output} --action stop" if pid else None} else: # start flask_config = analysis.get("flask", {}) route = flask_config.get("route", "/autodecoder") # The legacy Flask start deliberately selects a free fallback port. Do # not pre-emptively kill an arbitrary occupant here; --force is # retained for CLI compatibility but never authorizes that kill. return legacy_flask_start(args.flask_file, host, port, route) return {"status": "error", "hint": "unknown action"} # === State-file subcommand interface === def start_process(command: list[str], state_path: Path, kind: str) -> int: environment = None requested_port = None actual_port = None if kind == "flask": requested_port = int(os.environ.get("JSRA_FLASK_PORT", "5000")) actual_port = find_available_port(requested_port) if actual_port is None: print(json.dumps({"status": "port_occupied", "requested_port": requested_port}, ensure_ascii=False)) return 2 environment = os.environ.copy() environment["JSRA_FLASK_PORT"] = str(actual_port) process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, env=environment) time.sleep(.3) identity = process_identity(process.pid) dump_json(state_path, { "kind": kind, "command": command, "pid": process.pid, "exe": identity.get("exe"), "started": identity.get("started"), "observed_command": identity.get("command"), "created_at": time.time(), "requested_port": requested_port, "port": actual_port, "port_fallback": actual_port is not None and actual_port != requested_port }) print(json.dumps({"status": "started", "kind": kind, "pid": process.pid, "state": str(state_path), "port": actual_port}, ensure_ascii=False)) return 0 def stop_process(state_path: Path, kill_unknown: bool) -> int: state = load_json(state_path) identity = process_identity(int(state["pid"])) if not identity["alive"]: state_path.unlink(missing_ok=True) print("[OK] process already stopped") return 0 expected = (state.get("exe"), state.get("started"), state.get("observed_command")) actual = (identity.get("exe"), identity.get("started"), identity.get("command")) verifiable = any(value is not None for value in expected) if (not verifiable or expected != actual) and not kill_unknown: print(f"[ERROR] process identity cannot be verified; expected={expected}, actual={actual}") return 3 os.kill(int(state["pid"]), signal.SIGTERM) state_path.unlink(missing_ok=True) print(f"[OK] stopped pid {state['pid']}") return 0 def main() -> int: # Detect the legacy flag interface or the state-file subcommand interface. if len(sys.argv) > 1 and sys.argv[1] in ("start", "stop", "status"): # State-file subcommand mode parser = argparse.ArgumentParser(description="Safe service manager for JSRA.") sub = parser.add_subparsers(dest="command", required=True) start = sub.add_parser("start") start.add_argument("--kind", choices=["jsrpc", "flask"], required=True) start.add_argument("--state", required=True) start.add_argument("--jsrpc-binary") start.add_argument("--flask-script") stop = sub.add_parser("stop") stop.add_argument("--state", required=True) stop.add_argument("--kill-unknown", action="store_true") status = sub.add_parser("status") status.add_argument("--state", required=True) args = parser.parse_args() state = Path(args.state) if args.command == "start": if args.kind == "jsrpc": binary_path = args.jsrpc_binary if not binary_path: binary_path = find_jsrpc_binary() if not binary_path: parser.error("--jsrpc-binary is required; auto-discovery failed.") print(f"[INFO] Auto-discovered JSRPC binary: {binary_path}") binary = Path(binary_path).expanduser().resolve() if not binary.is_file() or not os.access(binary, os.X_OK): parser.error("invalid executable: " + str(binary)) return start_process([str(binary)], state, "jsrpc") if not args.flask_script: parser.error("--flask-script is required") return start_process([sys.executable, str(Path(args.flask_script).resolve())], state, "flask") if args.command == "stop": return stop_process(state, args.kill_unknown) info = load_json(state, {}) print(json.dumps({ "state": info, "process": process_identity(int(info["pid"])) if info.get("pid") else None }, ensure_ascii=False, indent=2)) return 0 else: # Legacy flag-based mode parser = argparse.ArgumentParser() parser.add_argument("--service", required=True, choices=["jsrpc", "flask"]) parser.add_argument("--analysis", required=True) parser.add_argument("--output", required=True) parser.add_argument("--flask-file", default="") parser.add_argument("--action", choices=["start", "stop", "status"], default="start") parser.add_argument("--force", action="store_true") args = parser.parse_args() result = legacy_mode(args) dump_json(Path(args.output), result) print(json.dumps(result, ensure_ascii=False)) return 0 if result.get("status") in ("already_running", "started", "running", "stopped", "not_running") else 1 if __name__ == "__main__": raise SystemExit(main())