409 lines
13 KiB
Python
Executable File
409 lines
13 KiB
Python
Executable File
#!/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()
|