262 lines
9.3 KiB
Python
262 lines
9.3 KiB
Python
|
|
#!/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()
|