# 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)