guanghu-ice-heart/zero-point/core-channel/revive-guard/pre-op-map-agent-v2.py

409 lines
15 KiB
Python

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