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:
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
|
||||
Reference in New Issue
Block a user