#!/usr/bin/env python3 """Outbound-only Linux node agent for Cang'er's home server.""" from __future__ import annotations import argparse import json import os import platform import shutil import socket import ssl import subprocess import sys import tempfile import threading import time from queue import Empty, Queue from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen AGENT_VERSION = "0.1.0" MAX_OUTPUT_CHARS = 240_000 def read_config(path: str) -> dict[str, Any]: with open(path, encoding="utf-8") as handle: value = json.load(handle) if not isinstance(value, dict): raise ValueError("config must be a JSON object") return value def write_config(path: str, value: dict[str, Any]) -> None: target = Path(path) target.parent.mkdir(parents=True, exist_ok=True) fd, temp_name = tempfile.mkstemp(prefix=".canger-node-", dir=target.parent) try: os.fchmod(fd, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(value, handle, ensure_ascii=False, indent=2) handle.write("\n") os.replace(temp_name, target) finally: if os.path.exists(temp_name): os.unlink(temp_name) def ssl_context(config: dict[str, Any]) -> ssl.SSLContext: context = ssl.create_default_context() if config.get("verify_tls", True) is False: if not str(config.get("server_url", "")).startswith( ("http://127.0.0.1", "http://localhost") ): raise ValueError("TLS verification may only be disabled for localhost") context.check_hostname = False context.verify_mode = ssl.CERT_NONE return context def api( config: dict[str, Any], method: str, path: str, body: dict[str, Any] | None = None, token: str | None = None, ) -> dict[str, Any]: base = str(config["server_url"]).rstrip("/") if not base.startswith(("https://", "http://127.0.0.1", "http://localhost")): raise ValueError("server_url must use HTTPS (HTTP is only allowed on localhost)") data = None if body is None else json.dumps(body).encode("utf-8") headers = {"Accept": "application/json"} if data is not None: headers["Content-Type"] = "application/json" if token: headers["Authorization"] = f"Bearer {token}" request = Request(base + path, data=data, headers=headers, method=method) try: with urlopen(request, timeout=30, context=ssl_context(config)) as response: value = json.load(response) except HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace") raise RuntimeError(f"server returned HTTP {exc.code}: {detail}") from exc if not isinstance(value, dict): raise RuntimeError("server returned a non-object response") return value def allowed_path(config: dict[str, Any], raw: str) -> Path: candidate = Path(raw).expanduser().resolve() roots = [Path(item).expanduser().resolve() for item in config["allowed_roots"]] if not any(candidate == root or root in candidate.parents for root in roots): raise PermissionError(f"path is outside allowed_roots: {candidate}") return candidate def run_process( argv: list[str], cwd: Path | None = None, timeout: int = 120, event_cb: Any = None, ) -> dict[str, Any]: started = time.monotonic() process = subprocess.Popen( argv, cwd=str(cwd) if cwd else None, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, shell=False, env={ "HOME": os.environ.get("HOME", "/root"), "LANG": os.environ.get("LANG", "C.UTF-8"), "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", }, ) lines: Queue[str | None] = Queue() def reader() -> None: assert process.stdout is not None for line in process.stdout: lines.put(line) lines.put(None) threading.Thread(target=reader, daemon=True).start() output_parts: list[str] = [] output_size = 0 batch: list[str] = [] last_emit = time.monotonic() ended = False try: while not ended: if time.monotonic() - started > timeout: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait() raise subprocess.TimeoutExpired(argv, timeout) try: line = lines.get(timeout=0.5) if line is None: ended = True else: if output_size < MAX_OUTPUT_CHARS: remaining = MAX_OUTPUT_CHARS - output_size kept = line[:remaining] output_parts.append(kept) output_size += len(kept) batch.append(line) except Empty: pass if batch and ( sum(map(len, batch)) >= 8192 or time.monotonic() - last_emit >= 1.0 or ended ): if event_cb: event_cb( "output", { "text": "".join(batch)[:16_000], "stream": "combined", }, ) batch.clear() last_emit = time.monotonic() return_code = process.wait() output = "".join(output_parts) return { "ok": return_code == 0, "exit_code": return_code, "duration_ms": round((time.monotonic() - started) * 1000), "output": output, "output_truncated": output_size >= MAX_OUTPUT_CHARS, } except subprocess.TimeoutExpired as exc: output = "".join(output_parts) return { "ok": False, "exit_code": None, "duration_ms": round((time.monotonic() - started) * 1000), "output": output, "output_truncated": output_size >= MAX_OUTPUT_CHARS, "error": "timeout", } def execute( config: dict[str, Any], task: dict[str, Any], event_cb: Any = None ) -> dict[str, Any]: action = task["action"] args = task.get("args") or {} enabled = set(config.get("enabled_actions", [])) if action not in enabled: raise PermissionError(f"action is disabled on this node: {action}") if action == "system.status": commands = [ ["hostname"], ["uname", "-a"], ["uptime"], ["df", "-h", "/"], ] results = [ run_process(command, timeout=30, event_cb=event_cb) for command in commands ] return { "ok": all(item["ok"] for item in results), "hostname": socket.gethostname(), "platform": platform.platform(), "commands": results, } if action == "repo.status": repo = allowed_path(config, args["repo"]) return run_process( [ "git", "-C", str(repo), "status", "--short", "--branch", ], timeout=60, event_cb=event_cb, ) if action == "repo.fetch": repo = allowed_path(config, args["repo"]) return run_process( ["git", "-C", str(repo), "fetch", "--prune", "origin"], timeout=300, event_cb=event_cb, ) if action == "service.logs": return run_process( [ "journalctl", "--no-pager", "-u", args["service"], "-n", str(args.get("lines", 200)), ], timeout=60, event_cb=event_cb, ) if action == "command.run": argv = args["argv"] executable = argv[0] if "/" in executable or executable not in set( config.get("executable_allowlist", []) ): raise PermissionError(f"executable is not allowlisted: {executable}") if shutil.which(executable) is None: raise FileNotFoundError(f"executable is not installed: {executable}") cwd = allowed_path(config, args["cwd"]) return run_process( argv, cwd=cwd, timeout=int(args.get("timeout_seconds", 300)), event_cb=event_cb, ) raise ValueError(f"unsupported action: {action}") def pair(config_path: str, code: str) -> None: config = read_config(config_path) if config.get("node_id") or config.get("node_token"): raise RuntimeError("node is already paired; remove its identity explicitly first") response = api( config, "POST", "/v1/pairings/claim", { "code": code, "node_name": config["node_name"], "agent_version": AGENT_VERSION, "policy": { "allowed_roots": config["allowed_roots"], "enabled_actions": config["enabled_actions"], }, }, ) config["node_id"] = response["node_id"] config["node_token"] = response["node_token"] write_config(config_path, config) print(f"paired node {config['node_name']} as {config['node_id']}") def run(config_path: str, once: bool = False) -> None: backoff = 2 while True: config = read_config(config_path) node_id = config.get("node_id") node_token = config.get("node_token") if not node_id or not node_token: raise RuntimeError("node is not paired") try: api( config, "POST", f"/v1/nodes/{node_id}/heartbeat", { "agent_version": AGENT_VERSION, "policy": { "allowed_roots": config["allowed_roots"], "enabled_actions": config["enabled_actions"], }, }, node_token, ) response = api( config, "GET", f"/v1/nodes/{node_id}/tasks/next", token=node_token, ) task = response.get("task") if task is not None: def emit(kind: str, payload: dict[str, Any]) -> None: try: api( config, "POST", f"/v1/tasks/{task['id']}/events", {"kind": kind, "payload": payload}, node_token, ) except Exception as exc: print(f"event upload failed: {exc}", file=sys.stderr) emit( "started", { "action": task["action"], "hostname": socket.gethostname(), "agent_version": AGENT_VERSION, }, ) try: result = execute(config, task, emit) except Exception as exc: result = { "ok": False, "error": type(exc).__name__, "message": str(exc), } emit( "finished", { "ok": bool(result.get("ok")), "exit_code": result.get("exit_code"), "error": result.get("error"), }, ) api( config, "POST", f"/v1/tasks/{task['id']}/result", result, node_token, ) backoff = 2 if once: return time.sleep(float(config.get("poll_seconds", 5))) except (OSError, URLError, RuntimeError) as exc: print(f"connection error: {exc}; retrying in {backoff}s", file=sys.stderr) if once: raise time.sleep(backoff) backoff = min(backoff * 2, 120) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument( "--config", default="/etc/canger-node/config.json", help="node config path" ) sub = parser.add_subparsers(dest="command", required=True) pair_parser = sub.add_parser("pair") pair_parser.add_argument("--code", required=True) run_parser = sub.add_parser("run") run_parser.add_argument("--once", action="store_true") return parser.parse_args() def main() -> None: args = parse_args() if args.command == "pair": pair(args.config, args.code) else: run(args.config, args.once) if __name__ == "__main__": main()