351 lines
14 KiB
Python
351 lines
14 KiB
Python
"""
|
||
Hook 模块管理服务
|
||
|
||
实现能力:
|
||
1. 模块管理(增删改查/启停/scope)
|
||
2. 脚本管理(保存/下载/部署)
|
||
3. 设备模块状态(加载/重载/日志)
|
||
4. Hook 事件总线(历史 + 实时订阅)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import base64
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Set
|
||
|
||
|
||
def _now_iso() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
class HookModuleService:
|
||
def __init__(self) -> None:
|
||
app_dir = Path(__file__).resolve().parent.parent
|
||
self.data_dir = app_dir / "data" / "hook"
|
||
self.scripts_dir = self.data_dir / "scripts"
|
||
self.modules_file = self.data_dir / "modules.json"
|
||
self.events_file = self.data_dir / "events.jsonl"
|
||
self.device_state_file = self.data_dir / "device_modules.json"
|
||
self._lock = asyncio.Lock()
|
||
self._event_clients: Set[Any] = set()
|
||
self._ensure_store()
|
||
|
||
def _ensure_store(self) -> None:
|
||
self.scripts_dir.mkdir(parents=True, exist_ok=True)
|
||
if not self.modules_file.exists():
|
||
self.modules_file.write_text("{}", encoding="utf-8")
|
||
if not self.device_state_file.exists():
|
||
self.device_state_file.write_text("{}", encoding="utf-8")
|
||
if not self.events_file.exists():
|
||
self.events_file.write_text("", encoding="utf-8")
|
||
|
||
def _read_json(self, path: Path, default: Any) -> Any:
|
||
try:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return default
|
||
|
||
def _write_json(self, path: Path, data: Any) -> None:
|
||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
async def list_modules(self, enabled: Optional[bool] = None, scope: Optional[str] = None) -> List[dict]:
|
||
async with self._lock:
|
||
modules = self._read_json(self.modules_file, {})
|
||
result = list(modules.values())
|
||
if enabled is not None:
|
||
result = [m for m in result if bool(m.get("enabled")) == enabled]
|
||
if scope:
|
||
result = [m for m in result if scope in (m.get("scopes") or [])]
|
||
return sorted(result, key=lambda x: x.get("updated_at", ""), reverse=True)
|
||
|
||
async def get_module(self, module_id: str) -> Optional[dict]:
|
||
async with self._lock:
|
||
modules = self._read_json(self.modules_file, {})
|
||
return modules.get(module_id)
|
||
|
||
async def upsert_module(self, payload: dict) -> dict:
|
||
module_id = payload["module_id"]
|
||
now = _now_iso()
|
||
script_content = payload.pop("script_content", None)
|
||
script_url = payload.get("script_url")
|
||
script_hash = payload.get("script_hash")
|
||
if script_content and not script_url:
|
||
decoded = base64.b64decode(script_content)
|
||
script_name = f"{module_id}.js"
|
||
target = self.scripts_dir / script_name
|
||
target.write_bytes(decoded)
|
||
digest = hashlib.sha256(decoded).hexdigest()
|
||
script_url = f"/api/v3/scripts/{module_id}"
|
||
script_hash = f"sha256:{digest}"
|
||
|
||
async with self._lock:
|
||
modules = self._read_json(self.modules_file, {})
|
||
old = modules.get(module_id, {})
|
||
data = {
|
||
"module_id": module_id,
|
||
"name": payload.get("name", old.get("name", module_id)),
|
||
"version": payload.get("version", old.get("version", "1.0.0")),
|
||
"description": payload.get("description", old.get("description", "")),
|
||
"enabled": bool(payload.get("enabled", old.get("enabled", True))),
|
||
"scopes": payload.get("scopes", old.get("scopes", ["com.tencent.mm"])),
|
||
"capabilities": payload.get("capabilities", old.get("capabilities", [])),
|
||
"min_frida_version": payload.get("min_frida_version", old.get("min_frida_version", "16.0.0")),
|
||
"script_url": script_url or old.get("script_url"),
|
||
"script_hash": script_hash or old.get("script_hash"),
|
||
"script_id": payload.get("script_id", old.get("script_id", module_id)),
|
||
"device_count": old.get("device_count", 0),
|
||
"error_count": old.get("error_count", 0),
|
||
"created_at": old.get("created_at", now),
|
||
"updated_at": now,
|
||
}
|
||
modules[module_id] = data
|
||
self._write_json(self.modules_file, modules)
|
||
return data
|
||
|
||
async def delete_module(self, module_id: str) -> bool:
|
||
async with self._lock:
|
||
modules = self._read_json(self.modules_file, {})
|
||
if module_id not in modules:
|
||
return False
|
||
del modules[module_id]
|
||
self._write_json(self.modules_file, modules)
|
||
return True
|
||
|
||
async def set_scope(self, module_id: str, scopes: List[str]) -> Optional[dict]:
|
||
async with self._lock:
|
||
modules = self._read_json(self.modules_file, {})
|
||
item = modules.get(module_id)
|
||
if not item:
|
||
return None
|
||
item["scopes"] = scopes
|
||
item["updated_at"] = _now_iso()
|
||
modules[module_id] = item
|
||
self._write_json(self.modules_file, modules)
|
||
return item
|
||
|
||
async def set_enabled(self, module_id: str, enabled: bool) -> Optional[dict]:
|
||
async with self._lock:
|
||
modules = self._read_json(self.modules_file, {})
|
||
item = modules.get(module_id)
|
||
if not item:
|
||
return None
|
||
item["enabled"] = enabled
|
||
item["updated_at"] = _now_iso()
|
||
modules[module_id] = item
|
||
self._write_json(self.modules_file, modules)
|
||
return item
|
||
|
||
async def list_scripts(self) -> List[dict]:
|
||
out: List[dict] = []
|
||
for p in sorted(self.scripts_dir.glob("*.js"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||
raw = p.read_bytes()
|
||
out.append(
|
||
{
|
||
"script_id": p.stem,
|
||
"filename": p.name,
|
||
"size": len(raw),
|
||
"hash": f"sha256:{hashlib.sha256(raw).hexdigest()}",
|
||
"url": f"/api/v3/scripts/{p.stem}",
|
||
"updated_at": datetime.fromtimestamp(p.stat().st_mtime, tz=timezone.utc).isoformat(),
|
||
}
|
||
)
|
||
return out
|
||
|
||
async def save_script(self, script_id: str, content: bytes) -> dict:
|
||
target = self.scripts_dir / f"{script_id}.js"
|
||
target.write_bytes(content)
|
||
digest = hashlib.sha256(content).hexdigest()
|
||
return {
|
||
"script_id": script_id,
|
||
"url": f"/api/v3/scripts/{script_id}",
|
||
"hash": f"sha256:{digest}",
|
||
"size": len(content),
|
||
}
|
||
|
||
async def get_script_path(self, script_id: str) -> Optional[Path]:
|
||
target = self.scripts_dir / f"{script_id}.js"
|
||
return target if target.exists() else None
|
||
|
||
async def get_device_modules(self, device_id: str) -> dict:
|
||
async with self._lock:
|
||
state = self._read_json(self.device_state_file, {})
|
||
item = state.get(device_id, {})
|
||
modules = item.get("modules", [])
|
||
return {
|
||
"device_id": device_id,
|
||
"supports_hook": item.get("supports_hook", False),
|
||
"frida_version": item.get("frida_version", ""),
|
||
"hook_framework": item.get("hook_framework", "frida-server"),
|
||
"root_status": item.get("root_status", False),
|
||
"modules": modules,
|
||
}
|
||
|
||
async def update_device_probe(self, device_id: str, probe: dict) -> None:
|
||
async with self._lock:
|
||
state = self._read_json(self.device_state_file, {})
|
||
item = state.get(device_id, {})
|
||
item.update(
|
||
{
|
||
"supports_hook": probe.get("supports_hook", False),
|
||
"frida_version": probe.get("frida_version", ""),
|
||
"root_status": probe.get("root_status", False),
|
||
"hook_framework": "frida-server",
|
||
"updated_at": _now_iso(),
|
||
}
|
||
)
|
||
item.setdefault("modules", [])
|
||
state[device_id] = item
|
||
self._write_json(self.device_state_file, state)
|
||
|
||
async def deploy_script(self, script_id: str, device_ids: List[str], auto_reload: bool = True) -> dict:
|
||
async with self._lock:
|
||
state = self._read_json(self.device_state_file, {})
|
||
deployed: List[str] = []
|
||
failed: List[dict] = []
|
||
reloaded: List[str] = []
|
||
now = _now_iso()
|
||
for did in device_ids:
|
||
item = state.setdefault(
|
||
did,
|
||
{"supports_hook": False, "frida_version": "", "root_status": False, "modules": []},
|
||
)
|
||
modules = item.setdefault("modules", [])
|
||
exists = None
|
||
for m in modules:
|
||
if m.get("module_id") == script_id:
|
||
exists = m
|
||
break
|
||
if exists:
|
||
exists["status"] = "loaded"
|
||
exists["loaded_at"] = now
|
||
else:
|
||
modules.append(
|
||
{
|
||
"module_id": script_id,
|
||
"version": "1.0.0",
|
||
"status": "loaded",
|
||
"loaded_at": now,
|
||
"target_process": "com.tencent.mm",
|
||
"target_pid": 0,
|
||
"rpc_methods": [],
|
||
"last_error": None,
|
||
"events_today": 0,
|
||
}
|
||
)
|
||
deployed.append(did)
|
||
if auto_reload:
|
||
reloaded.append(did)
|
||
self._write_json(self.device_state_file, state)
|
||
return {"deployed": deployed, "failed": failed, "reloaded": reloaded}
|
||
|
||
async def reload_device_modules(self, device_id: str, module_ids: List[str], force: bool = False) -> dict:
|
||
async with self._lock:
|
||
state = self._read_json(self.device_state_file, {})
|
||
item = state.get(device_id)
|
||
if not item:
|
||
return {"reloaded": [], "failed": module_ids}
|
||
mods = item.get("modules", [])
|
||
found, failed = [], []
|
||
now = _now_iso()
|
||
for mid in module_ids:
|
||
hit = None
|
||
for m in mods:
|
||
if m.get("module_id") == mid:
|
||
hit = m
|
||
break
|
||
if hit:
|
||
hit["status"] = "loaded"
|
||
hit["loaded_at"] = now
|
||
if force:
|
||
hit["last_error"] = None
|
||
found.append(mid)
|
||
else:
|
||
failed.append(mid)
|
||
item["modules"] = mods
|
||
state[device_id] = item
|
||
self._write_json(self.device_state_file, state)
|
||
return {"reloaded": found, "failed": failed}
|
||
|
||
async def add_device_log(self, device_id: str, module_id: str, line: str) -> None:
|
||
async with self._lock:
|
||
state = self._read_json(self.device_state_file, {})
|
||
item = state.setdefault(device_id, {"modules": []})
|
||
logs = item.setdefault("logs", {})
|
||
bucket = logs.setdefault(module_id, [])
|
||
bucket.append({"ts": _now_iso(), "line": line})
|
||
if len(bucket) > 300:
|
||
logs[module_id] = bucket[-300:]
|
||
state[device_id] = item
|
||
self._write_json(self.device_state_file, state)
|
||
|
||
async def get_device_logs(self, device_id: str, module_id: str) -> List[dict]:
|
||
async with self._lock:
|
||
state = self._read_json(self.device_state_file, {})
|
||
item = state.get(device_id, {})
|
||
logs = item.get("logs", {}).get(module_id, [])
|
||
return logs[-200:]
|
||
|
||
async def add_event(self, event: dict) -> dict:
|
||
data = {"event_id": f"evt_{int(datetime.now().timestamp()*1000)}", **event}
|
||
if "timestamp" not in data:
|
||
data["timestamp"] = _now_iso()
|
||
async with self._lock:
|
||
with self.events_file.open("a", encoding="utf-8") as f:
|
||
f.write(json.dumps(data, ensure_ascii=False) + "\n")
|
||
await self._broadcast_event({"type": "hook_event", "data": data})
|
||
return data
|
||
|
||
async def list_events(
|
||
self,
|
||
event_type: Optional[str] = None,
|
||
device_id: Optional[str] = None,
|
||
platform: Optional[str] = None,
|
||
limit: int = 100,
|
||
) -> List[dict]:
|
||
events: List[dict] = []
|
||
if not self.events_file.exists():
|
||
return events
|
||
lines = self.events_file.read_text(encoding="utf-8").splitlines()[-2000:]
|
||
for line in reversed(lines):
|
||
if len(events) >= limit:
|
||
break
|
||
try:
|
||
item = json.loads(line)
|
||
except Exception:
|
||
continue
|
||
if event_type and item.get("event_type") != event_type:
|
||
continue
|
||
if device_id and item.get("device_id") != device_id:
|
||
continue
|
||
if platform and item.get("platform") != platform:
|
||
continue
|
||
events.append(item)
|
||
return events
|
||
|
||
async def attach_ws_client(self, ws: Any) -> None:
|
||
self._event_clients.add(ws)
|
||
|
||
async def detach_ws_client(self, ws: Any) -> None:
|
||
self._event_clients.discard(ws)
|
||
|
||
async def _broadcast_event(self, payload: dict) -> None:
|
||
if not self._event_clients:
|
||
return
|
||
dead = []
|
||
for ws in list(self._event_clients):
|
||
try:
|
||
await ws.send_json(payload)
|
||
except Exception:
|
||
dead.append(ws)
|
||
for ws in dead:
|
||
self._event_clients.discard(ws)
|
||
|
||
|
||
hook_module_service = HookModuleService()
|