cang-ying/remote-node/node/canger_node.py

409 lines
13 KiB
Python
Raw Normal View History

#!/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()