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:
2026-10-16 17:33:24 -04:00
parent c4bb374ccc
commit 426682a999
7 changed files with 331 additions and 11 deletions
+17
View File
@@ -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
+5
View File
@@ -69,3 +69,8 @@ POLL_MS_UPS=5000
# Debug: DEBUGPY=1 makes the app listen for a debugger on 5678 # Debug: DEBUGPY=1 makes the app listen for a debugger on 5678
DEBUGPY=0 DEBUGPY=0
DEBUGPY_PORT=5678 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.
+52
View File
@@ -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"]
+22 -7
View File
@@ -45,14 +45,23 @@ uv run dashboards
```bash ```bash
python3 host_agent.py --once # print one snapshot, exit python3 host_agent.py --once # print one snapshot, exit
python3 host_agent.py --host 0.0.0.0 --port 9120 --token … # serve, token required python3 host_agent.py --host 0.0.0.0 --port 9120 --token-file /etc/dashboards-agent.token
deploy/install-agent.sh # systemd unit (run with sudo) 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 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 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. 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 ## Configuration
Everything comes from the environment (see `.env.example` for the annotated list). At 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 ## Deployment
`Containerfile` builds a slim image (the agent is copied in too, so one image can serve a `Containerfile` is a two-stage build: `uv.lock` is resolved in the builder (exactly the
host if you want). In the deployment repo the pattern is the same as every other service: versions the tests ran against) and the runtime stage is slim Python with nothing but the
quadlet container + env template + nginx/SNI on the single domain. 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 ```bash
podman build -t dashboards . 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.
+25 -4
View File
@@ -941,6 +941,20 @@ def serve(host: str, port: int, collector: Collector, token: str | None) -> None
httpd.server_close() 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: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0], parser = argparse.ArgumentParser(description=__doc__.splitlines()[0],
formatter_class=argparse.ArgumentDefaultsHelpFormatter) 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("--port", type=int, default=int(os.environ.get("AGENT_PORT", "9120")))
parser.add_argument("--token", default=os.environ.get("AGENT_TOKEN", ""), parser.add_argument("--token", default=os.environ.get("AGENT_TOKEN", ""),
help="bearer token required on every request except /health") 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("--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("--top", type=int, default=5, help="processes per top-N list")
parser.add_argument("--net-all", action="store_true", help="include veth/bridge interfaces") 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: if args.once:
print(json.dumps({"ok": True, **collector.snapshot()}, indent=2, default=str)) print(json.dumps({"ok": True, **collector.snapshot()}, indent=2, default=str))
return 0 return 0
if args.host not in ("127.0.0.1", "localhost", "::1") and not args.token: token, error = resolve_token(args.token, args.token_file)
print("[agent] refusing to bind off-loopback without --token (host metrics are " if error:
"sensitive); pass --token or set AGENT_TOKEN", flush=True) print(f"[agent] {error}", flush=True)
return 2 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 return 0
+176
View File
@@ -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" <<UNIT_FILE
[Unit]
Description=Homelab host agent (read-only system metrics)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$AGENT_USER
ExecStart=/usr/bin/env python3 $AGENT_TARGET --host $BIND --port $PORT --token-file $TOKEN_FILE
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
SyslogIdentifier=$SERVICE
# The agent reads /proc, /sys and statvfs() and writes nothing, so everything writable can
# be taken away. PrivateDevices keeps /dev closed; the snapshot never needs it.
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=read-only
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
CapabilityBoundingSet=
SystemCallFilter=@system-service
[Install]
WantedBy=multi-user.target
UNIT_FILE
systemctl daemon-reload
systemctl enable --now "$SERVICE"
sleep 1
if systemctl is-active --quiet "$SERVICE"; then
echo " $SERVICE is running on $BIND:$PORT"
else
echo " $SERVICE failed to start — last lines:" >&2
journalctl -u "$SERVICE" -n 20 --no-pager >&2
exit 1
fi
cat <<NEXT
Point the dashboard at it (the token is the URL userinfo, so it never needs a variable of
its own, and the API strips it before anything reaches a browser):
HOST_TARGETS="hostname=http://$TOKEN@$BIND:$PORT"
Paste the token now if this is the first install — it is not printed again without
--rotate-token, and it is only ever readable by root on this box:
$TOKEN
NEXT
}
case "$MODE" in
install) install ;;
status) status ;;
uninstall) uninstall ;;
esac
+34
View File
@@ -282,3 +282,37 @@ def test_agent_is_dependency_free() -> None:
assert top <= {"__future__", "argparse", "collections", "dataclasses", "http", "json", assert top <= {"__future__", "argparse", "collections", "dataclasses", "http", "json",
"os", "pathlib", "re", "socket", "subprocess", "threading", "time", "os", "pathlib", "re", "socket", "subprocess", "threading", "time",
"typing"}, f"agent import footprint grew: {sorted(top)}" "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"