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:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
+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
|
||||
|
||||
|
||||
|
||||
Executable
+176
@@ -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
|
||||
@@ -282,3 +282,37 @@ def test_agent_is_dependency_free() -> 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"
|
||||
|
||||
Reference in New Issue
Block a user