Files
reverse-skills-max/vendor-new/js-reverse-automation--skill/js-reverse-automation/scripts/manage_services.py

428 lines
20 KiB
Python

#!/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())