phase: 10_dev_direct_dial
**Phase 10 final pass — repo green; live criteria blocked off-repo.**
- Verified: `gateway` runs `dashboards-agent` (active/enabled, `10.1.1.27:9120` → `/health` 200, `/snapshot` 401 no-token / 200 bearer on-host, token matches `/etc/dashboards-agent.token`); `driveripper`/`deskwork` inactive, `sudo -n` → "a password is required", `:9120` empty.
- New measurement (transient runtime rule on gateway `firewall-cmd --add-port=9120/tcp`, **reverted** — runtime+permanent port lists verified back to the original set, no rich rules): laptop still gets no SYN through, while `:22`/`:80` on the same address answer in ms ⇒ the block is the lab gateway `10.2.0.1` port filter, not this app/host firewall/agent. No other live change made (no sudoers, no upsd, no UPS command).
- Tests/lint (exact): `bash .agents/validate.sh` → **EXIT 0** — `uv run pytest --cov --cov-fail-under=94` **793 passed, 11 skipped, coverage 98.33 %**; `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean; hygiene clean; `uv pip list --outdated` empty (bumped fastapi 0.143.3→0.143.5, `uv.lock` only).
- `uv run pytest -m live --live` (`.env.dev` sourced): **4 passed, 1 skipped, 6 failed** — all six are `:9120`/`:3051` connect timeouts; zero parse/contract failures.
- Dev server restarted on current code: engines `ok`; 3×`host` + 3×`disks` + `nut` `DOWN` with real reasons (designed degradation, stub rows intact, no credential leakage).
- Completion criteria: screenshots ✓ (`data/logs/phase10-{index,system,disks,llm,ups}-live.png`); tunnel-vocabulary grep ✓ outside `.agents/` history (PLAN.md locked line, phase overviews, past reports; fixtures/`uv.lock` hits are digit substrings — `tests/test_deploy_scripts.py` guards operational files); no page-payload change ✓ (contract tests). **Not met, operator-side:** agents on all three hosts, `LIST UPS` from the laptop, `dev-server.sh status` all-ok, live suite green.
- Fixed in this pass: `sources/nut.py` unreachable-upsd message no longer prints a bare `(TimeoutError: )` (now `(timed out after 4s)` / `(ConnectionRefusedError: …)`), pinned by a new test + extended refused-port test.
- Docs: README verified consistent (every env var in `config.py` documented, routes/flags/`--with-llm`/pager sizes checked) — no changes needed. AGENTS.md fixed 4 inconsistencies: per-host sudo reality (gateway grants `NOPASSWD ALL`, which is why it installed without `agent-sudoers`; driveripper scoped; deskwork unrelated), the now-proven firewall finding, `/system` table typo ("agent per host"), gap-4 DB/WAL sizes + test count. `.env.example` `upsd.users` sample now one `instcmds` per line (it contradicted README and the in-app copy). `.env.dev` (gitignored) comment corrected to match the evidence.
- Flagged, untouched (locked): `.agents/PLAN.md` L92–95 still says upsd is "forwarded to `127.0.0.1:3494` in dev" — false after this phase; evidence + exact operator commands appended to `data/logs/NOTES-verify.md` §11.
- Next pending phase: `11_admin_login`.
This commit is contained in:
+72
-16
@@ -1,15 +1,20 @@
|
||||
#!/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 # loopback only (read it locally)
|
||||
# sudo ./deploy/install-agent.sh --bind 10.0.0.5 # serve the LAN, token required
|
||||
# sudo ./deploy/install-agent.sh --status
|
||||
# ./deploy/install-agent.sh --status # unit state + /health, no root
|
||||
# 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.
|
||||
# No root shell? `deploy/agent-sudoers` is the single sudoers line that lets the fleet's
|
||||
# unprivileged `ops` account run this script as root. Run it without root and it prints that
|
||||
# exact line with this script's resolved path, then exits 1.
|
||||
#
|
||||
# The token is stored in a file only root and the agent's own service user can read, and is
|
||||
# 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
|
||||
@@ -32,6 +37,15 @@ 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"
|
||||
# Absolute, symlink-resolved path of this script: the sudoers grant in deploy/agent-sudoers
|
||||
# is matched on a path, so the non-root hint below prints the line that actually matches
|
||||
# wherever the script happens to live (the canonical staging path is
|
||||
# /home/ops/agent-install/deploy/install-agent.sh).
|
||||
SCRIPT_PATH="$SCRIPT_DIR/$(basename "$0")"
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
SCRIPT_PATH="$(realpath "$0" 2>/dev/null || printf '%s' "$SCRIPT_PATH")"
|
||||
fi
|
||||
SUDOERS_SOURCE="$(dirname "$SCRIPT_PATH")/agent-sudoers"
|
||||
|
||||
BIND="127.0.0.1"
|
||||
PORT="9120"
|
||||
@@ -60,7 +74,30 @@ done
|
||||
|
||||
need_root() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "run as root: sudo $0 $*" >&2
|
||||
# The dev workflow deploys over ssh as the unprivileged `ops` account, so "run as root"
|
||||
# is not an action the operator can take. Print the one sudoers line that makes this
|
||||
# script installable — the same line as deploy/agent-sudoers, with this script's own
|
||||
# resolved path — plus the two-stage install it documents, then exit 1 as before.
|
||||
cat >&2 <<HINT
|
||||
$SCRIPT_PATH has to run as root: it writes $UNIT,
|
||||
creates the $AGENT_USER user and its disk-group membership, and stores
|
||||
the token in $TOKEN_FILE.
|
||||
|
||||
Add this single sudoers line on this host (it is the whole grant; see $SUDOERS_SOURCE):
|
||||
|
||||
ops ALL=(root) NOPASSWD: $SCRIPT_PATH
|
||||
|
||||
Install it the two-stage way every sudoers change here uses, as root — the command-scoped
|
||||
ops sudo on these hosts does not include visudo, so this step is the operator's:
|
||||
|
||||
sudo visudo -cf $SUDOERS_SOURCE
|
||||
sudo install -m 0440 -o root -g root $SUDOERS_SOURCE /etc/sudoers.d/dashboards-agent
|
||||
sudo visudo -cf /etc/sudoers.d/dashboards-agent
|
||||
|
||||
Then run this same command again:
|
||||
|
||||
sudo $SCRIPT_PATH $*
|
||||
HINT
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -76,7 +113,7 @@ status() {
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
need_root "$0 --uninstall"
|
||||
need_root --uninstall
|
||||
systemctl disable --now "$SERVICE" 2>/dev/null || true
|
||||
rm -f "$UNIT" "$TOKEN_FILE" "$AGENT_TARGET"
|
||||
rmdir "$AGENT_DIR" 2>/dev/null || true
|
||||
@@ -90,8 +127,12 @@ new_token() {
|
||||
python3 -c 'import secrets; print(secrets.token_urlsafe(32).rstrip("="))'
|
||||
}
|
||||
|
||||
install() {
|
||||
need_root "$0 --bind $BIND --port $PORT"
|
||||
# NOT named `install`: bash resolves a function before an external command of the same name,
|
||||
# so an `install()` here turns the `install -d` / `install -m` calls below into infinite
|
||||
# recursion instead of creating $AGENT_DIR. Phase 10 task 02 found exactly that on a live
|
||||
# host: the run looped on the SMART block forever and installed nothing.
|
||||
do_install() {
|
||||
need_root --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
|
||||
@@ -127,11 +168,14 @@ install() {
|
||||
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.
|
||||
# 0640 root:$AGENT_USER. The unit passes `--token-file` as a *path*: systemd does not read
|
||||
# the secret for the service, the agent process (running as $AGENT_USER) opens it itself,
|
||||
# so a 0600 root-owned file makes the agent exit "cannot read --token-file" and the unit
|
||||
# never starts. Group ownership is the smallest grant that lets the service read it, and
|
||||
# every other account — ops included — still cannot.
|
||||
(umask 0177; printf '%s\n' "$TOKEN" > "$TOKEN_FILE")
|
||||
chmod 0600 "$TOKEN_FILE"
|
||||
chown root:root "$TOKEN_FILE"
|
||||
chmod 0640 "$TOKEN_FILE"
|
||||
chown root:"$AGENT_USER" "$TOKEN_FILE"
|
||||
|
||||
cat > "$UNIT" <<UNIT_FILE
|
||||
[Unit]
|
||||
@@ -151,10 +195,22 @@ SyslogIdentifier=$SERVICE
|
||||
# be taken away. smartctl is the one thing that needs a real /dev node, so instead of
|
||||
# PrivateDevices the unit closes all devices and reopens only the disk nodes, read/write
|
||||
# at the cgroup layer while the code (and smartctl -i -A -H) stays read-only by design.
|
||||
#
|
||||
# The pseudo nodes are not decoration: DevicePolicy=strict denies every node that is not
|
||||
# listed, and systemd needs /dev/null for the service's stdin before it ever execs python —
|
||||
# without it the unit dies at spawn with "Failed at step STDIN" (status 208), which is how
|
||||
# the first phase-10 install failed on a live host running systemd 257. /dev/zero, full,
|
||||
# random and urandom are the rest of the set a PrivateDevices= sandbox would have provided,
|
||||
# and none of them is a disk.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=false
|
||||
DevicePolicy=strict
|
||||
DeviceAllow=/dev/null rw
|
||||
DeviceAllow=/dev/zero rw
|
||||
DeviceAllow=/dev/full rw
|
||||
DeviceAllow=/dev/random rw
|
||||
DeviceAllow=/dev/urandom rw
|
||||
DeviceAllow=/dev/nvme* rw
|
||||
DeviceAllow=/dev/sd* rw
|
||||
DeviceAllow=/dev/vd* rw
|
||||
@@ -194,14 +250,14 @@ 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:
|
||||
--rotate-token, and only root and the agent's own service user can read it on this box:
|
||||
|
||||
$TOKEN
|
||||
NEXT
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
install) install ;;
|
||||
install) do_install ;;
|
||||
status) status ;;
|
||||
uninstall) uninstall ;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user