409 lines
15 KiB
Python
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())
|