guanghu-ice-heart/server-tools/zhulan-remote-cell/runtime/zhulan_validation_executor.py

262 lines
9.3 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Local-only allowlisted Bubblewrap validation broker for the Zhulan runtime."""
from __future__ import annotations
import json
import os
import signal
import socketserver
import sqlite3
import subprocess
import time
from pathlib import Path
from typing import Any
MAX_REQUEST_BYTES = 64 * 1024
def safe_relative(value: Any) -> str:
if not isinstance(value, str):
raise PermissionError("validation_path_invalid")
raw = value.replace("\\", "/").strip("/")
if raw in {"", "."}:
return "."
parts = raw.split("/")
if any(part in {"", ".", ".."} for part in parts):
raise PermissionError("validation_path_invalid")
return "/".join(parts)
def path_within(path: str, prefix: str) -> bool:
return prefix == "." or path == prefix or path.startswith(prefix + "/")
class ValidationExecutor:
def __init__(self) -> None:
self.workspace_root = Path(
os.getenv("ZHULAN_WORKSPACE_ROOT", "/srv/guanghu/zhulan-cell/workspaces")
).resolve()
self.policy_path = Path(
os.getenv("ZHULAN_POLICY", "/opt/guanghu/zhulan-remote-cell/policy.json")
)
self.state_db = Path(
os.getenv("ZHULAN_DB", "/var/lib/guanghu/zhulan-remote-cell/state.sqlite3")
)
self.bwrap = Path(
os.getenv(
"ZHULAN_BWRAP", "/opt/guanghu/zhulan-remote-cell/runtime/zhulan-bwrap"
)
)
def policy(self) -> dict[str, Any]:
return json.loads(self.policy_path.read_text(encoding="utf-8"))
def validate(self, request: dict[str, Any]) -> tuple[Path, list[str], list[str]]:
if set(request) != {
"operation",
"request_id",
"repository",
"development_id",
"workspace",
"paths",
"command",
}:
raise PermissionError("validation_request_fields_invalid")
if request.get("operation") != "run":
raise PermissionError("validation_operation_invalid")
repository = str(request.get("repository", ""))
request_id = str(request.get("request_id", ""))
development_id = str(request.get("development_id", ""))
command = request.get("command")
paths = request.get("paths")
if not isinstance(command, list) or not command or not all(
isinstance(item, str) for item in command
):
raise PermissionError("validation_command_invalid")
if not isinstance(paths, list) or not paths:
raise PermissionError("validation_paths_invalid")
clean_paths = sorted(set(safe_relative(item) for item in paths))
policy = self.policy()
repository_policy = policy.get("repositories", {}).get(repository)
if not isinstance(repository_policy, dict):
raise PermissionError("validation_repository_not_registered")
allowed = repository_policy.get("validation_commands", [])
if command not in allowed or command == ["git", "diff", "--check"]:
raise PermissionError("validation_command_not_registered_for_broker")
allowed_prefixes = [safe_relative(item) for item in repository_policy.get("allowed_path_prefixes", ["."])]
if any(not any(path_within(path, prefix) for prefix in allowed_prefixes) for path in clean_paths):
raise PermissionError("validation_path_outside_repository_policy")
uri = f"file:{self.state_db}?mode=ro"
with sqlite3.connect(uri, uri=True, timeout=5) as db:
db.row_factory = sqlite3.Row
row = db.execute(
"""SELECT id, state, picked_up_at, capability_expires_at,
development_id, repository, paths_json, actions_json
FROM requests WHERE id=?""",
(request_id,),
).fetchone()
if (
not row
or row["state"] != "APPROVED"
or not row["picked_up_at"]
or int(row["capability_expires_at"] or 0) <= int(time.time())
):
raise PermissionError("validation_approval_not_active")
if (
row["development_id"] != development_id
or row["repository"] != repository
or json.loads(row["paths_json"]) != clean_paths
or "test" not in json.loads(row["actions_json"])
):
raise PermissionError("validation_approval_binding_mismatch")
workspace = Path(str(request.get("workspace", ""))).resolve()
workspace.relative_to(self.workspace_root)
if (
not workspace.is_dir()
or not (workspace / ".git").is_dir()
or (workspace / ".git").is_symlink()
):
raise PermissionError("validation_workspace_invalid")
owner, name = repository.split("/", 1)
if (
workspace.name != f"{owner}__{name}"
or workspace.parent.name != development_id
):
raise PermissionError("validation_workspace_repository_mismatch")
if not self.bwrap.is_file():
raise RuntimeError("validation_bwrap_missing")
return workspace, clean_paths, command
@staticmethod
def projection(workspace: Path, paths: list[str]) -> list[str]:
if paths == ["."]:
return ["--ro-bind", str(workspace), "/workspace"]
result: list[str] = []
created = {"/workspace"}
for relative in paths:
target = workspace / relative
destination = Path("/workspace") / relative
parents = [item for item in reversed(destination.parents) if str(item).startswith("/workspace")]
for parent in parents:
text = str(parent)
if text not in created:
result.extend(["--dir", text])
created.add(text)
if target.exists():
target.resolve().relative_to(workspace)
result.extend(["--ro-bind", str(target), str(destination)])
elif str(destination) not in created:
result.extend(["--dir", str(destination)])
created.add(str(destination))
return result
def execute(self, request: dict[str, Any]) -> dict[str, Any]:
if request.get("operation") == "ping":
if set(request) != {"operation"}:
raise PermissionError("validation_ping_fields_invalid")
return {"ok": True, "executor": "zhulan-validation"}
workspace, paths, command = self.validate(request)
argv = [
str(self.bwrap),
"--die-with-parent",
"--new-session",
"--unshare-user",
"--unshare-pid",
"--unshare-net",
"--unshare-ipc",
"--unshare-uts",
"--ro-bind",
"/usr",
"/usr",
"--ro-bind",
"/bin",
"/bin",
"--ro-bind-try",
"/lib",
"/lib",
"--ro-bind-try",
"/lib64",
"/lib64",
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
"--dir",
"/home",
"--dir",
"/home/zhulan",
*self.projection(workspace, paths),
"--chdir",
"/workspace",
"--clearenv",
"--setenv",
"HOME",
"/home/zhulan",
"--setenv",
"PATH",
"/usr/local/bin:/usr/bin:/bin",
"--",
*command,
]
result = subprocess.run(
argv,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=180,
env={"PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": "C.UTF-8"},
)
return {"ok": True, "exit_code": result.returncode, "output": result.stdout[-12000:]}
class Handler(socketserver.StreamRequestHandler):
def handle(self) -> None:
raw = self.rfile.readline(MAX_REQUEST_BYTES + 1)
if not raw or len(raw) > MAX_REQUEST_BYTES:
return
try:
request = json.loads(raw)
if not isinstance(request, dict):
raise ValueError("request_not_object")
response = self.server.executor.execute(request) # type: ignore[attr-defined]
except Exception as exc:
response = {"ok": False, "error": str(exc)[:300]}
self.wfile.write((json.dumps(response, sort_keys=True) + "\n").encode("utf-8"))
class Server(socketserver.UnixStreamServer):
allow_reuse_address = True
def main() -> None:
socket_path = Path(
os.getenv(
"ZHULAN_VALIDATION_SOCKET", "/run/guanghu/zhulan-validation/executor.sock"
)
)
socket_path.parent.mkdir(parents=True, exist_ok=True)
if socket_path.exists():
socket_path.unlink()
server = Server(str(socket_path), Handler)
server.executor = ValidationExecutor() # type: ignore[attr-defined]
os.chmod(socket_path, 0o600)
def stop(_signum: int, _frame: object) -> None:
server.server_close()
raise SystemExit(0)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
try:
server.serve_forever()
finally:
server.server_close()
socket_path.unlink(missing_ok=True)
if __name__ == "__main__":
main()