"""Shared protocol rules for the Cang'er remote node.""" from __future__ import annotations import hashlib import hmac import json import re import secrets import time from typing import Any MAX_JSON_BYTES = 256 * 1024 MAX_RESULT_BYTES = 512 * 1024 PAIRING_TTL_SECONDS = 10 * 60 TASK_LEASE_SECONDS = 5 * 60 ACTION_NAMES = { "system.status", "repo.status", "repo.fetch", "service.logs", "command.run", } NODE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}$") def now_ts() -> int: return int(time.time()) def new_id(prefix: str) -> str: return f"{prefix}_{secrets.token_hex(12)}" def new_token() -> str: return secrets.token_urlsafe(32) def new_pairing_code() -> str: alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" return "-".join( "".join(secrets.choice(alphabet) for _ in range(4)) for _ in range(3) ) def secret_hash(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() def secret_matches(value: str, expected_hash: str) -> bool: return hmac.compare_digest(secret_hash(value), expected_hash) def compact_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) def validate_node_name(value: Any) -> str: if not isinstance(value, str) or not NODE_NAME_RE.fullmatch(value): raise ValueError("node_name must use 1-64 letters, digits, dot, underscore or dash") return value def validate_task(action: Any, args: Any) -> tuple[str, dict[str, Any]]: if not isinstance(action, str) or action not in ACTION_NAMES: raise ValueError(f"unsupported action: {action!r}") if args is None: args = {} if not isinstance(args, dict): raise ValueError("args must be an object") if action == "system.status": if args: raise ValueError("system.status does not accept args") elif action in {"repo.status", "repo.fetch"}: if set(args) != {"repo"} or not isinstance(args["repo"], str): raise ValueError(f"{action} requires string arg: repo") elif action == "service.logs": if not isinstance(args.get("service"), str) or not SERVICE_NAME_RE.fullmatch( args["service"] ): raise ValueError("service.logs requires a safe service name") lines = args.get("lines", 200) if not isinstance(lines, int) or not 1 <= lines <= 2000: raise ValueError("service.logs lines must be between 1 and 2000") args = {"service": args["service"], "lines": lines} elif action == "command.run": argv = args.get("argv") cwd = args.get("cwd") timeout = args.get("timeout_seconds", 300) if ( not isinstance(argv, list) or not argv or len(argv) > 64 or any(not isinstance(item, str) or len(item) > 4096 for item in argv) ): raise ValueError("command.run argv must be a non-empty string array") if not isinstance(cwd, str): raise ValueError("command.run requires string arg: cwd") if not isinstance(timeout, int) or not 1 <= timeout <= 1800: raise ValueError("command.run timeout_seconds must be between 1 and 1800") args = {"argv": argv, "cwd": cwd, "timeout_seconds": timeout} encoded = compact_json(args).encode("utf-8") if len(encoded) > MAX_JSON_BYTES: raise ValueError("task args are too large") return action, args