Add the deployment path: agent systemd unit and container image
The README referenced `deploy/install-agent.sh` and a `Containerfile` that did not exist, so a fresh clone had no way to actually run this anywhere. * agent: `--token-file` (and AGENT_TOKEN_FILE). The token guards hostname, mounts, disk and GPU models and process names, and argv is world-readable through `ps`, so a file has to win over the flag. Missing or empty token files are fatal — falling back to "no token" would expose the host. * deploy/install-agent.sh: creates a system user, generates the token once (re-runs reuse it, --rotate-token for a new one), stores it 0600 root-owned, and runs the agent under ProtectSystem=strict with no capabilities; --status and --uninstall included. * Containerfile: two-stage, uv.lock resolved in the builder and slim Python in the runtime. Verified with podman — the image serves /api/health as an unprivileged user and writes its history under /var/lib/dashboards. UV_PROJECT_ENVIRONMENT and --no-editable are what make the venv survive being copied into the runtime stage. * .containerignore keeps .git, .venv, data/ and .env out of the build context. 258 tests, 94% coverage; ruff and pyright clean. LLM-Generated: true
This commit is contained in:
+25
-4
@@ -941,6 +941,20 @@ def serve(host: str, port: int, collector: Collector, token: str | None) -> None
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def resolve_token(token: str, token_file: str) -> tuple[str | None, str | None]:
|
||||
"""(token, error). A file wins over a flag because `ps` shows argv to every user on the
|
||||
box, and this token guards hostname, mounts, GPU model and process names."""
|
||||
if not token_file:
|
||||
return (token or None), None
|
||||
try:
|
||||
value = Path(token_file).read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
return None, f"cannot read --token-file {token_file}: {exc}"
|
||||
if not value:
|
||||
return None, f"--token-file {token_file} is empty"
|
||||
return value, None
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0],
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
@@ -949,6 +963,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument("--port", type=int, default=int(os.environ.get("AGENT_PORT", "9120")))
|
||||
parser.add_argument("--token", default=os.environ.get("AGENT_TOKEN", ""),
|
||||
help="bearer token required on every request except /health")
|
||||
parser.add_argument("--token-file", default=os.environ.get("AGENT_TOKEN_FILE", ""),
|
||||
help="read the bearer token from a file (keeps it out of ps); wins "
|
||||
"over --token")
|
||||
parser.add_argument("--root", default="/", help="filesystem root to read (for tests)")
|
||||
parser.add_argument("--top", type=int, default=5, help="processes per top-N list")
|
||||
parser.add_argument("--net-all", action="store_true", help="include veth/bridge interfaces")
|
||||
@@ -959,11 +976,15 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.once:
|
||||
print(json.dumps({"ok": True, **collector.snapshot()}, indent=2, default=str))
|
||||
return 0
|
||||
if args.host not in ("127.0.0.1", "localhost", "::1") and not args.token:
|
||||
print("[agent] refusing to bind off-loopback without --token (host metrics are "
|
||||
"sensitive); pass --token or set AGENT_TOKEN", flush=True)
|
||||
token, error = resolve_token(args.token, args.token_file)
|
||||
if error:
|
||||
print(f"[agent] {error}", flush=True)
|
||||
return 2
|
||||
serve(args.host, args.port, collector, args.token or None)
|
||||
if args.host not in ("127.0.0.1", "localhost", "::1") and not token:
|
||||
print("[agent] refusing to bind off-loopback without --token (host metrics are "
|
||||
"sensitive); pass --token, --token-file or set AGENT_TOKEN", flush=True)
|
||||
return 2
|
||||
serve(args.host, args.port, collector, token)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user