Files
dashboards/.agents/phases/todo/15_nut_tls_credentials/00_phase.md
T
ducoterra eb4788cba1 phases: convert TODO.md to phases 12-15 (store, NUT verdicts, LLM page, TLS)
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
2026-10-19 01:36:07 -04:00

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.users account (SET/INSTCMD still need it, per AGENTS.md's verified fleet facts; NUT has no wire-level client-certificate check for commands, and its own HELP verb list shows USERNAME/PASSWORD as 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). The available_commands() limitation (the wire cannot tell what the account is granted, AGENTS.md known gap 1) is unchanged — the risk tiers and the typed DANGER stay load-bearing.

Dependencies

  • 05_ups_hero_battery_glyph (complete) — the NutClient/NutMonitor/COMMANDS surface 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 file config.py: 11 appends the four DASH_* auth settings, this phase appends the NUT_TLS* block; whichever runs second applies on top of the file state at that time, never restoring the other's lines. api.py is 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

  1. 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 guessed upsd.conf line).
  2. 02_nutclient_starttls.md — STARTTLS in the NutClient: stdlib ssl, 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.
  3. 03_config_gates_and_redaction.md — the NUT_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.
  4. 04_hermetic_tls_tests.md — the loopback-TLS fake upsd, the STARTTLS framing pin (a server without the branch fails exactly as the USER/PASS regression does), wrong-CA rejection, the gate over TLS, the no-leak test, and the read-only live test.

Testing & Quality

  • Unit: tests/test_nut.py — STARTTLS framing (the fake upsd's STARTTLS branch), server-cert verification (a self-signed server the local CA did not issue → refused, with a redacted message), client-certificate presentation (the loopback server with CERT_REQUIRED sees the dashboard's client cert), the USERNAME/PASSWORD login over TLS (the gate still works through the upgrade), and the cleartext path unmodified (NUT_TLS off → no STARTTLS sent, 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 event row that carries it contain no key material and no NUT_TLS_DIR contents (the URL redaction doctrine, extended to key material).
  • Live (-m live --live, read-only per the AGENTS.md live rule): a STARTTLS-capable probe of the real upsd (does the deployed 2.8.5 answer the verb — HELP says it does; the probe records the answer), no INSTCMD sent 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 in scripts/) 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 in data/logs/, not a memory of a man page)
  • with NUT_TLS=1 and the material in place, NutClient completes STARTTLS, verifies the server, and the LOGIN + a GET reading succeed over the upgraded socket (the hermetic loopback; the live probe for the real verb answer)
  • with NUT_TLS unset (the default), every pre-existing tests/test_nut.py behaviour is byte-identical — no STARTTLS on the wire, cleartext reads unchanged
  • no key material in any health record, event row, error message or /api/config answer (the no-leak test + the redaction suite)
  • uv run pytest green, bash .agents/validate.sh green
  • README.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)