The five TODO items become four phases: 12 NUT self-test verdict wording (assess() passes through 'Done and passed' to warn), 13 LLM page fleet/strip spacing plus the decode tok/s spike (labelled counter sums, unbounded estimator span), 14 store performance (full-scan read paths, maintain() rollup write window, WAL tuning), 15 NUT privileged-action security (collector-managed root CA, STARTTLS, account gate documented). TODO.md is cleared; .agents/phases/todo/ is the source of truth. LLM-Generated: true
7.0 KiB
Phase 15 — The upsd wire: a managed root CA, STARTTLS, and the account gate
Source: TODO.md L6 — "For privileged actions (UPS commands) the exporter should be installed with password protection and that password must be used to register it with the collector. Effectively the exporters become protected web servers. Additionally, the collector should generate and manage a root certificate that needs to be installed with each exporter. In fact, just use the certs as auth rather than a password."
Story: n/a
Context: src/dashboards/sources/nut.py (the ~10-line NutClient wire protocol, login()'s USERNAME/PASSWORD verbs pinned by test_login_uses_the_verbs_upsd_2_8_5_answers_to, assess()/COMMANDS risk tiers), src/dashboards/collectors.py (NutMonitor, the redacted-URL error doctrine), src/dashboards/api.py (the one POST /api/ups/command and its four gates), src/dashboards/config.py (env-only settings), tests/test_nut.py (the fake upsd), tests/fixtures/nut_apc.vars / nut_cyberpower.vars (the wire-verb ground truth, incl. HELP's advertised verbs: GET LIST SET INSTCMD LOGIN LOGOUT USERNAME PASSWORD STARTTLS), scripts/, README.md → NUT
Objective
The privileged action in this app is POST /api/ups/command, and its security today
rests on the upsd.users account (the password the operator installed on the
exporter, carried in the collector's config and used at LOGIN before every
INSTCMD — the "register with the collector" half is already true) plus the four
gates. What it does not have is transport security: the account password and the
command travel in cleartext on the wire. This phase gives the wire what the TODO asks
for, in the form NUT 2.8.5 can express: the collector generates and manages a root
CA, issues a server certificate for the exporter (installed on it by the operator)
and a client certificate for itself; the NutClient upgrades the connection with
STARTTLS, verifies the exporter's certificate against the local CA (the collector
dials the real upsd, not a lookalike) and presents its own client certificate.
- ASSUMPTION (the load-bearing one): "just use the certs as auth rather than a
password" is implemented as certs authenticate the transport, the account
password still gates the command — the deployed upsd 2.8.5's command gate is the
upsd.usersaccount (SET/INSTCMDstill need it, per AGENTS.md's verified fleet facts; NUT has no wire-level client-certificate check for commands, and its ownHELPverb list showsUSERNAME/PASSWORDas the auth pair). A client certificate presented to a server that does not check it is a trust slot for the next NUT, not the gate today; the gate that exists today is made usable only over TLS in the documented deployment, which is the protection the TODO is after (password + commands off the cleartext wire, both endpoints authenticated). Theavailable_commands()limitation (the wire cannot tell what the account is granted, AGENTS.md known gap 1) is unchanged — the risk tiers and the typedDANGERstay load-bearing.
Dependencies
05_ups_hero_battery_glyph(complete) — theNutClient/NutMonitor/COMMANDSsurface this phase extends.01…04(complete) —collectors.py's redacted-URL error doctrine (invariant 6) that the TLS error messages must follow.11_admin_login(todo) — shared fileconfig.py: 11 appends the fourDASH_*auth settings, this phase appends theNUT_TLS*block; whichever runs second applies on top of the file state at that time, never restoring the other's lines.api.pyis also shared (11's gate vs this phase's none — this phase adds no route and changes no route; the one POST stays the one POST).
Tasks
01_ca_and_certificate_tooling.md— the collector's root CA: generation script, the 0600 material layout, and the upsd-side install steps verified against the deployed upsd 2.8.5 (rule 7: no guessedupsd.confline).02_nutclient_starttls.md—STARTTLSin theNutClient: stdlibssl, server verified against the local CA, client certificate presented, the rest of the protocol byte-identical, and the cleartext path unchanged when TLS is off.03_config_gates_and_redaction.md— theNUT_TLS*settings (off by default), the one-POST-four-gates story made explicit (the account password is the registration credential, unchanged), and invariant 6: no key material in anything that leaves the process.04_hermetic_tls_tests.md— the loopback-TLS fake upsd, theSTARTTLSframing pin (a server without the branch fails exactly as theUSER/PASSregression does), wrong-CA rejection, the gate over TLS, the no-leak test, and the read-onlylivetest.
Testing & Quality
- Unit:
tests/test_nut.py—STARTTLSframing (the fake upsd'sSTARTTLSbranch), server-cert verification (a self-signed server the local CA did not issue → refused, with a redacted message), client-certificate presentation (the loopback server withCERT_REQUIREDsees the dashboard's client cert), theUSERNAME/PASSWORDlogin over TLS (the gate still works through the upgrade), and the cleartext path unmodified (NUT_TLSoff → noSTARTTLSsent, every pre-existing test passes unmodified in intent). - Unit: the no-leak test — a TLS failure's message, the health record it becomes and
the
eventrow that carries it contain no key material and noNUT_TLS_DIRcontents (the URL redaction doctrine, extended to key material). - Live (
-m live --live, read-only per the AGENTS.md live rule): aSTARTTLS-capable probe of the real upsd (does the deployed 2.8.5 answer the verb —HELPsays it does; the probe records the answer), noINSTCMDsent from the test (a battery test on hardware something is plugged into is not a test). - Coverage: >90% on new/modified code; the 94% gate must hold.
Completion Criteria
scripts/nut-tls-certs.sh(or the Python equivalent inscripts/) generates the CA + both certificates on a clean machine; the material is 0600, outside git, and the README's NUT section walks the operator through installing the server certificate on the exporter (verified against the deployed upsd 2.8.5 — the exact config indata/logs/, not a memory of a man page)- with
NUT_TLS=1and the material in place,NutClientcompletesSTARTTLS, verifies the server, and theLOGIN+ aGETreading succeed over the upgraded socket (the hermetic loopback; theliveprobe for the real verb answer) - with
NUT_TLSunset (the default), every pre-existingtests/test_nut.pybehaviour is byte-identical — noSTARTTLSon the wire, cleartext reads unchanged - no key material in any health record, event row, error message or
/api/configanswer (the no-leak test + the redaction suite) uv run pytestgreen,bash .agents/validate.shgreenREADME.md's NUT section documents the CA, the install steps, the password-still-gates-the-command reality, and why (the NUT 2.8.5 constraint)