#!/usr/bin/env python3 """光湖服务器入口地图 Agent v2. This component is intentionally read-only. It verifies and presents the node's canonical navigation map when an interactive shell starts, then writes an acknowledgement receipt. Authorization APIs remain responsible for enforcing MapGate before registered actions. """ from __future__ import annotations import argparse import hashlib import json import os import re import socket import subprocess import sys import tempfile from datetime import datetime, timezone from pathlib import Path from typing import Any DEFAULT_MAP_ROOT = Path("/etc/guanghu/navigation-maps") DEFAULT_RECEIPT_ROOT = Path("/var/lib/guanghu/map-agent/receipts") class MapAgentError(RuntimeError): """A map cannot be selected or verified.""" SAFE_UNIT = re.compile(r"^[A-Za-z0-9_.@-]+\.service$") SAFE_PM2_NAME = re.compile(r"^[A-Za-z0-9_.@-]+$") def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for block in iter(lambda: handle.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def select_map(explicit_path: str | None, map_root: Path) -> Path: if explicit_path: path = Path(explicit_path) if not path.is_file(): raise MapAgentError(f"MAP_MISSING: {path}") return path node_id = os.environ.get("GUANGHU_NODE_ID", "").strip() if node_id: path = map_root / f"{node_id}.json" if not path.is_file(): raise MapAgentError(f"MAP_MISSING: {path}") return path candidates = sorted(map_root.glob("*.json")) if len(candidates) == 1: return candidates[0] if not candidates: raise MapAgentError(f"MAP_MISSING: no JSON map under {map_root}") raise MapAgentError( "NODE_UNKNOWN: set GUANGHU_NODE_ID when more than one map is installed" ) def verify_map(path: Path) -> tuple[dict[str, Any], str]: companion = path.with_suffix(path.suffix + ".sha256") if not companion.is_file(): raise MapAgentError(f"MAP_HASH_MISSING: {companion}") expected = companion.read_text(encoding="utf-8").split()[0].lower() if len(expected) != 64 or any(ch not in "0123456789abcdef" for ch in expected): raise MapAgentError(f"MAP_HASH_INVALID: {companion}") actual = sha256_file(path) if actual != expected: raise MapAgentError( f"MAP_HASH_MISMATCH: expected={expected[:16]} actual={actual[:16]}" ) try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise MapAgentError(f"MAP_INVALID: {exc}") from exc required = ("schema_version", "node_id", "node_role", "modules", "routes") missing = [key for key in required if key not in data] if missing: raise MapAgentError(f"MAP_INVALID: missing {','.join(missing)}") if not isinstance(data["modules"], list) or not isinstance(data["routes"], list): raise MapAgentError("MAP_INVALID: modules and routes must be arrays") return data, actual def run_probe(probe: dict[str, Any], deep_audit: bool) -> dict[str, Any]: probe_type = probe.get("type") required = bool(probe.get("required", True)) if bool(probe.get("deep_only", False)) and not deep_audit: return { "type": probe_type, "required": required, "status": "skipped", "detail": "deep audit only", } try: if probe_type in {"file_exists", "directory_exists"}: path = Path(str(probe.get("path", ""))) if not path.is_absolute(): raise ValueError("path must be absolute") ok = path.is_file() if probe_type == "file_exists" else path.is_dir() detail = str(path) elif probe_type == "file_sha256": path = Path(str(probe.get("path", ""))) expected = str(probe.get("sha256", "")).lower() if not path.is_absolute() or len(expected) != 64: raise ValueError("invalid path or sha256") actual = sha256_file(path) if path.is_file() else "" ok = actual == expected detail = f"{path} sha256={actual or 'missing'}" elif probe_type == "systemd_active": unit = str(probe.get("unit", "")) if not SAFE_UNIT.fullmatch(unit): raise ValueError("invalid systemd unit") result = subprocess.run( ["systemctl", "is-active", "--quiet", unit], check=False, timeout=float(probe.get("timeout_seconds", 3)), ) ok = result.returncode == 0 detail = unit elif probe_type == "pm2_online": name = str(probe.get("name", "")) if not SAFE_PM2_NAME.fullmatch(name): raise ValueError("invalid pm2 name") result = subprocess.run( ["pm2", "jlist"], check=False, capture_output=True, text=True, timeout=float(probe.get("timeout_seconds", 4)), ) processes = json.loads(result.stdout or "[]") if result.returncode == 0 else [] ok = any( item.get("name") == name and item.get("pm2_env", {}).get("status") == "online" for item in processes ) detail = name elif probe_type == "tcp_connect": host = str(probe.get("host", "127.0.0.1")) port = int(probe.get("port", 0)) if host not in {"127.0.0.1", "::1", "localhost"}: raise ValueError("only loopback TCP probes are allowed") if not 1 <= port <= 65535: raise ValueError("invalid TCP port") with socket.create_connection( (host, port), timeout=float(probe.get("timeout_seconds", 1.5)) ): pass ok = True detail = f"{host}:{port}" elif probe_type == "manifest_verify": manifest = Path(str(probe.get("path", ""))) if not deep_audit: return { "type": probe_type, "required": required, "status": "skipped", "detail": "deep audit only", } if not manifest.is_absolute() or not manifest.is_file(): raise ValueError("manifest is missing or path is not absolute") result = subprocess.run( ["sha256sum", "--check", "--strict", str(manifest)], cwd=str(manifest.parent), check=False, capture_output=True, text=True, timeout=float(probe.get("timeout_seconds", 180)), ) ok = result.returncode == 0 detail = str(manifest) else: raise ValueError(f"unsupported probe type {probe_type!r}") except ( FileNotFoundError, json.JSONDecodeError, OSError, subprocess.TimeoutExpired, ValueError, ) as exc: ok = False detail = str(exc) return { "type": probe_type, "required": required, "status": "pass" if ok else "fail", "detail": detail, } def audit_runtime(data: dict[str, Any], deep_audit: bool) -> dict[str, Any]: module_results = [] route_results = [] for module in data["modules"]: probes = [run_probe(item, deep_audit) for item in module.get("probes", [])] failed = [item for item in probes if item["required"] and item["status"] == "fail"] module_results.append( { "code": module.get("code", "UNREGISTERED"), "name": module.get("name", "unnamed"), "status": "verified" if not failed else "drift", "probes": probes, } ) for route in data["routes"]: probes = [run_probe(item, deep_audit) for item in route.get("probes", [])] failed = [item for item in probes if item["required"] and item["status"] == "fail"] route_results.append( { "target": route.get("target", "unknown"), "purpose": route.get("purpose", "unspecified"), "status": "verified" if not failed else "drift", "probes": probes, } ) drift = [ item["code"] for item in module_results if item["status"] == "drift" ] + [ f"route:{item['target']}" for item in route_results if item["status"] == "drift" ] return { "status": "MAP_VERIFIED" if not drift else "MAP_DRIFT", "modules": module_results, "routes": route_results, "drift": drift, "deep_audit": deep_audit, } def render_map(data: dict[str, Any], digest: str, audit: dict[str, Any]) -> str: modules = data["modules"] routes = data["routes"] lines = [ "", "╔════════════════════════════════════════════════════╗", f"║ 光湖服务器入口地图 Agent v2 · {audit['status']:<18}║", "╚════════════════════════════════════════════════════╝", f" 节点: {data['node_id']}", f" 角色: {data['node_role']}", f" 地图哈希: {digest}", f" 地图生成: {data.get('generated_at', 'unknown')}", "", f" 运行模块 ({len(modules)}):", ] runtime_by_code = {item["code"]: item for item in audit["modules"]} for module in modules: runtime = runtime_by_code.get(module.get("code"), {}) lines.append( " - {code} · {name} · 期望:{expected} · 实时:{actual}".format( code=module.get("code", "UNREGISTERED"), name=module.get("name", "unnamed"), expected=module.get("expected_state", "present"), actual=runtime.get("status", "unknown"), ) ) lines.append("") lines.append(f" 已登记路由 ({len(routes)}):") runtime_by_target = {item["target"]: item for item in audit["routes"]} for route in routes: runtime = runtime_by_target.get(route.get("target"), {}) lines.append( " - {target} · {purpose} · 实时:{state}".format( target=route.get("target", "unknown"), purpose=route.get("purpose", "unspecified"), state=runtime.get("status", "unknown"), ) ) if audit["drift"]: lines.extend(["", f" 漂移编号: {', '.join(audit['drift'])}"]) lines.extend( [ "", " 边界: 本入口只恢复地图;受控动作仍须经过工单、授权与 MapGate。", "", ] ) return "\n".join(lines) def safe_receipt_root(requested: Path) -> Path: try: requested.mkdir(parents=True, exist_ok=True, mode=0o750) return requested except PermissionError: runtime = os.environ.get("XDG_RUNTIME_DIR") fallback = Path(runtime) / "guanghu-map-agent" if runtime else Path( tempfile.gettempdir() ) / f"guanghu-map-agent-{os.getuid()}" fallback.mkdir(parents=True, exist_ok=True, mode=0o700) if fallback.stat().st_uid != os.getuid(): raise MapAgentError(f"RECEIPT_PATH_UNSAFE: {fallback}") return fallback def write_receipt( data: dict[str, Any], digest: str, audit: dict[str, Any], receipt_root: Path, persona: str, ) -> Path: receipt_root = safe_receipt_root(receipt_root) now = datetime.now(timezone.utc) receipt = { "schema_version": "guanghu.map-entry-receipt/v2", "node_id": data["node_id"], "map_sha256": digest, "persona": persona, "user": os.environ.get("USER", "unknown"), "host": socket.gethostname(), "read_at": now.isoformat().replace("+00:00", "Z"), "status": audit["status"], "drift": audit["drift"], "deep_audit": audit["deep_audit"], "module_results": audit["modules"], "route_results": audit["routes"], } name = f"{now.strftime('%Y%m%dT%H%M%S%fZ')}-{os.getpid()}.json" target = receipt_root / name fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(receipt, handle, ensure_ascii=False, indent=2) handle.write("\n") return target def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Verify and present a Guanghu node map") parser.add_argument("--map", dest="map_path") parser.add_argument("--map-root", default=str(DEFAULT_MAP_ROOT)) parser.add_argument("--receipt-root", default=str(DEFAULT_RECEIPT_ROOT)) parser.add_argument("--persona", default=os.environ.get("GUANGHU_PERSONA", "unknown")) parser.add_argument("--json", action="store_true") parser.add_argument("--no-receipt", action="store_true") parser.add_argument("--static-only", action="store_true") parser.add_argument("--deep-audit", action="store_true") return parser.parse_args() def main() -> int: args = parse_args() try: path = select_map(args.map_path, Path(args.map_root)) data, digest = verify_map(path) audit = ( { "status": "MAP_VERIFIED", "modules": [], "routes": [], "drift": [], "deep_audit": False, } if args.static_only else audit_runtime(data, args.deep_audit) ) receipt = None if not args.no_receipt: receipt = write_receipt( data, digest, audit, Path(args.receipt_root), args.persona ) if args.json: print( json.dumps( { "status": audit["status"], "node_id": data["node_id"], "map_sha256": digest, "module_count": len(data["modules"]), "route_count": len(data["routes"]), "drift": audit["drift"], "deep_audit": audit["deep_audit"], "receipt": str(receipt) if receipt else None, }, ensure_ascii=False, indent=2, ) ) else: print(render_map(data, digest, audit)) return 0 if audit["status"] == "MAP_VERIFIED" else 24 except (MapAgentError, OSError) as exc: print(f"光湖地图入口拒绝继续: {exc}", file=sys.stderr) return 23 if __name__ == "__main__": raise SystemExit(main())