diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..1bd1810 --- /dev/null +++ b/.containerignore @@ -0,0 +1,17 @@ +# Keep the build context small and local state out of it. The image copies exactly what it +# needs (manifest, src/, agent/), so nothing here is missed — a stray .env or .venv in the +# context is how secrets end up in an image layer by accident. +.git +.github +.venv +.pytest_cache +.ruff_cache +.coverage +data +tests +.env +.env.* +agents +.agents +Containerfile +.containerignore diff --git a/.env.example b/.env.example index 474835e..0356ada 100644 --- a/.env.example +++ b/.env.example @@ -69,3 +69,8 @@ POLL_MS_UPS=5000 # Debug: DEBUGPY=1 makes the app listen for a debugger on 5678 DEBUGPY=0 DEBUGPY_PORT=5678 + +# The host agent reads its OWN variables (AGENT_HOST, AGENT_PORT, AGENT_TOKEN, +# AGENT_TOKEN_FILE). They belong on the monitored host — in the root-owned env/token files +# that deploy/install-agent.sh writes — not in this file: a dashboard config that everyone +# copies tends to end up with the fleet's tokens in it. diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..db1812f --- /dev/null +++ b/Containerfile @@ -0,0 +1,52 @@ +# Homelab dashboards — one image, three dashboards, one port. +# +# podman build -t dashboards . +# podman run --rm -p 8090:8090 --env-file dashboards.env \ +# -v dashboards-data:/var/lib/dashboards dashboards +# +# Two stages: the builder resolves `uv.lock` (exactly the versions the tests ran against), +# the runtime stage is a plain slim Python with only the installed venv in it. No build +# toolchain, no source, no dev dependencies in the final image. + +FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder +WORKDIR /app +# The venv is built at the path it will run from: a venv moved out of place has shebangs and +# a `bin/python` symlink pointing at wherever it was built, which fails at exec time with a +# confusing "missing dynamic library?". +ENV UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never UV_PROJECT_ENVIRONMENT=/opt/venv +# manifest first: a source-only change must not re-resolve the dependency graph +COPY pyproject.toml uv.lock README.md ./ +RUN uv sync --frozen --no-dev --no-install-project +COPY src ./src +# --no-editable matters: uv installs the project editable by default, which leaves a .pth +# pointing at /app/src — a path that only exists in the builder, and the app then dies at +# import time with "No module named dashboards". +RUN uv sync --frozen --no-editable + +FROM python:3.14-slim +# no image.source label: the origin is set once this repo has one +LABEL org.opencontainers.image.title="homelab dashboards" \ + org.opencontainers.image.description="System, LLM engine and UPS battery dashboards on one domain" + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH=/opt/venv/bin:$PATH \ + DASH_HOST=0.0.0.0 \ + DASH_PORT=8090 \ + DASH_DB=/var/lib/dashboards/dashboards.sqlite3 + +RUN useradd --system --uid 10001 --home-dir /var/lib/dashboards --create-home dashboards +COPY --from=builder /opt/venv /opt/venv +# the host agent ships in the same image so one container can monitor the box it runs on +COPY agent/host_agent.py /opt/dashboards/agent/host_agent.py + +VOLUME /var/lib/dashboards +EXPOSE 8090 +USER dashboards + +# /api/health answers "empty" (200) with no sources configured, so a container that starts +# before its env file is right still passes the probe instead of crash-looping. +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD ["python", "-c", "import sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8090/api/health', timeout=4).status == 200 else 1)"] + +CMD ["dashboards"] diff --git a/README.md b/README.md index 63d51cc..9738106 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,23 @@ uv run dashboards ```bash python3 host_agent.py --once # print one snapshot, exit -python3 host_agent.py --host 0.0.0.0 --port 9120 --token … # serve, token required -deploy/install-agent.sh # systemd unit (run with sudo) +python3 host_agent.py --host 0.0.0.0 --port 9120 --token-file /etc/dashboards-agent.token +sudo deploy/install-agent.sh # systemd unit + user + token +deploy/install-agent.sh --status # unit state and /health +sudo deploy/install-agent.sh --uninstall ``` It is read-only by construction: there is no code path that writes, and the HTTP server only answers `GET`. Counters are reported as counters — the dashboard computes rates over a labelled window, so the agent never has to remember who polled it last. +Binding off-loopback without a token is refused (exit 2) rather than warned about. Prefer +`--token-file` over `--token`: argv is world-readable through `ps`, and this token guards +hostname, mounts, disk and GPU models, and process names. `deploy/install-agent.sh` creates +a system user, generates a 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 — enough to read `/proc`, `/sys` and `statvfs()` and nothing else. + ## Configuration Everything comes from the environment (see `.env.example` for the annotated list). At @@ -130,13 +139,19 @@ outright while the UPS is already on battery. ## Deployment -`Containerfile` builds a slim image (the agent is copied in too, so one image can serve a -host if you want). In the deployment repo the pattern is the same as every other service: -quadlet container + env template + nginx/SNI on the single domain. +`Containerfile` is a two-stage build: `uv.lock` is resolved in the builder (exactly the +versions the tests ran against) and the runtime stage is slim Python with nothing but the +venv in it. The host agent is copied in too, so one image can monitor the box it runs on. +In the deployment repo the pattern is the same as every other service: quadlet container + +env template + nginx/SNI on the single domain. ```bash podman build -t dashboards . -podman run --rm -p 8090:8090 --env-file dashboards.env dashboards +podman run --rm -p 8090:8090 --env-file dashboards.env \ + -v dashboards-data:/var/lib/dashboards dashboards ``` -Keep the SQLite file on persistent storage (`-v dashboards-data:/var/lib/dashboards`). +It listens on `0.0.0.0:8090` as an unprivileged user, and the SQLite history lives in +`/var/lib/dashboards` — put a volume there or every restart forgets the charts. With no +sources configured `/api/health` answers `200 {"status": "empty"}`, so the container passes +its health probe before its env file is right instead of crash-looping. diff --git a/agent/host_agent.py b/agent/host_agent.py index 5a52b92..0db10b9 100644 --- a/agent/host_agent.py +++ b/agent/host_agent.py @@ -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 diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh new file mode 100755 index 0000000..4a84ad1 --- /dev/null +++ b/deploy/install-agent.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# Install agent/host_agent.py as a systemd service on a monitored host. +# +# sudo ./deploy/install-agent.sh # loopback only (ssh tunnel in) +# sudo ./deploy/install-agent.sh --bind 10.0.0.5 # serve the LAN, token required +# sudo ./deploy/install-agent.sh --status +# sudo ./deploy/install-agent.sh --uninstall +# +# The token is stored in a root-owned 0600 file and passed to the agent as --token-file, +# never on the command line: argv is world-readable through `ps`, and this token guards +# hostname, mounts, disk models, GPU model and process names. Re-running the installer +# reuses the existing token — use --rotate-token when the old one leaked. +# +# The agent is read-only by construction (it answers GET and writes nothing), runs as its +# own system user, and gets the usual systemd lockdown. It still needs to read /proc, /sys +# and statvfs(), which the hardening below leaves alone. +set -euo pipefail + +SERVICE="dashboards-agent" +AGENT_USER="dashboards-agent" +AGENT_DIR="/usr/local/lib/dashboards-agent" +AGENT_TARGET="$AGENT_DIR/host_agent.py" +TOKEN_FILE="/etc/dashboards-agent.token" +UNIT="/etc/systemd/system/$SERVICE.service" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +AGENT_SOURCE="$SCRIPT_DIR/../agent/host_agent.py" + +BIND="127.0.0.1" +PORT="9120" +TOKEN="${AGENT_TOKEN:-}" +ROTATE=0 +MODE="install" + +usage() { + # the header comment *is* the help text, so it cannot drift from the flags + awk 'NR > 1 { if (/^#/) { sub(/^# ?/, ""); print } else { exit } }' "$0" + exit "${1:-0}" +} + +while [ $# -gt 0 ]; do + case "$1" in + --bind) BIND="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --token) TOKEN="$2"; shift 2 ;; + --rotate-token) ROTATE=1; shift ;; + --status) MODE="status"; shift ;; + --uninstall) MODE="uninstall"; shift ;; + -h|--help) usage 0 ;; + *) echo "unknown argument: $1" >&2; usage 2 ;; + esac +done + +need_root() { + if [ "$(id -u)" -ne 0 ]; then + echo "run as root: sudo $0 $*" >&2 + exit 1 + fi +} + +status() { + systemctl status "$SERVICE" --no-pager 2>&1 || true + echo + if command -v curl >/dev/null 2>&1; then + printf 'health: ' + curl -s --max-time 4 "http://$BIND:$PORT/health" || echo "(no answer)" + echo + fi +} + +uninstall() { + need_root "$0 --uninstall" + systemctl disable --now "$SERVICE" 2>/dev/null || true + rm -f "$UNIT" "$TOKEN_FILE" "$AGENT_TARGET" + rmdir "$AGENT_DIR" 2>/dev/null || true + systemctl daemon-reload + echo "removed $SERVICE (the $AGENT_USER user was left in place; remove it with userdel)" +} + +new_token() { + # urlsafe and no padding: the token goes into HOST_TARGETS as URL userinfo + # (`name=http://TOKEN@host:9120`), where '+' and '=' are annoying to quote. + python3 -c 'import secrets; print(secrets.token_urlsafe(32).rstrip("="))' +} + +install() { + need_root "$0 --bind $BIND --port $PORT" + [ -f "$AGENT_SOURCE" ] || { echo "no agent next to this script: $AGENT_SOURCE" >&2; exit 1; } + if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required on the monitored host (3.10 or newer)" >&2 + exit 1 + fi + # The agent needs no privileges, so give it a user with nothing to lose. + if ! id "$AGENT_USER" >/dev/null 2>&1; then + useradd --system --home-dir / --shell /usr/sbin/nologin "$AGENT_USER" + fi + + install -d -m 0755 "$AGENT_DIR" + install -m 0755 "$AGENT_SOURCE" "$AGENT_TARGET" + + if [ -f "$TOKEN_FILE" ] && [ "$ROTATE" -eq 0 ] && [ -z "$TOKEN" ]; then + TOKEN="$(cat "$TOKEN_FILE")" + echo "reusing the existing token in $TOKEN_FILE (--rotate-token for a new one)" + elif [ -z "$TOKEN" ]; then + TOKEN="$(new_token)" + fi + # 0600 root:root — the service runs as $AGENT_USER, and systemd (PID 1, root) reads the + # file for it, so the agent process never needs read permission on it itself. + (umask 0177; printf '%s\n' "$TOKEN" > "$TOKEN_FILE") + chmod 0600 "$TOKEN_FILE" + chown root:root "$TOKEN_FILE" + + cat > "$UNIT" <&2 + journalctl -u "$SERVICE" -n 20 --no-pager >&2 + exit 1 + fi + cat < None: assert top <= {"__future__", "argparse", "collections", "dataclasses", "http", "json", "os", "pathlib", "re", "socket", "subprocess", "threading", "time", "typing"}, f"agent import footprint grew: {sorted(top)}" + + +# ---- how the token is supplied ------------------------------------------- # + +def test_token_file_wins_over_the_command_line(tmp_path) -> None: + path = tmp_path / "token" + path.write_text("from-file\n", encoding="utf-8") + token, error = host_agent.resolve_token("from-flag", str(path)) + assert error is None + assert token == "from-file", "argv is world-readable in ps; the file has to win" + + +def test_a_missing_token_file_is_fatal_and_says_which_one(tmp_path) -> None: + missing = tmp_path / "nope" + token, error = host_agent.resolve_token("", str(missing)) + assert token is None + assert error and str(missing) in error, "a silent fallback to no-token would expose the host" + + +def test_an_empty_token_file_is_fatal(tmp_path) -> None: + path = tmp_path / "token" + path.write_text(" \n", encoding="utf-8") + token, error = host_agent.resolve_token("ignored", str(path)) + assert token is None and error is not None + + +def test_no_token_at_all_is_allowed_on_loopback(tmp_path) -> None: + token, error = host_agent.resolve_token("", "") + assert (token, error) == (None, None) + + +def test_main_refuses_to_bind_off_loopback_without_a_token(tmp_path) -> None: + code = host_agent.main(["--host", "0.0.0.0", "--token-file", str(tmp_path / "missing")]) + assert code == 2, "the agent must not start exposed and unauthenticated"