diff --git a/.agents/phases/todo/15_nut_tls_credentials/00_phase.md b/.agents/phases/todo/15_nut_tls_credentials/00_phase.md deleted file mode 100644 index 7d80fd4..0000000 --- a/.agents/phases/todo/15_nut_tls_credentials/00_phase.md +++ /dev/null @@ -1,90 +0,0 @@ -# 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) diff --git a/.agents/phases/todo/15_nut_tls_credentials/01_ca_and_certificate_tooling.md b/.agents/phases/todo/15_nut_tls_credentials/01_ca_and_certificate_tooling.md deleted file mode 100644 index 64e93c0..0000000 --- a/.agents/phases/todo/15_nut_tls_credentials/01_ca_and_certificate_tooling.md +++ /dev/null @@ -1,77 +0,0 @@ -# Task 01 — The collector's root CA: tooling and the verified upsd-side install - -**Phase:** `15_nut_tls_credentials` · **Source:** `TODO.md:6` — "the collector should generate and manage a root certificate that needs to be installed with each exporter" - -## Objective - -The certificate side of "the collector manages the root certificate": a script that -generates the local CA, the exporter's server certificate and the collector's client -certificate with sane defaults, the material layout on disk, and — per rule 7 -(verify against the deployed upstream, never guess) — the **actual** upsd 2.8.5 -server-side configuration, verified on the host and recorded, not a man-page memory. -No key material ever enters a tracked file, and the generator is re-runnable (a -rotation is a re-run, not a migration). - -## Work - -1. `scripts/nut-tls-certs.sh` (bash + `openssl`, stdlib tooling — no new dependency, - the locked-decisions doctrine): generate, under `NUT_TLS_DIR` (default - `~/.config/dashboards/nut-tls/`, mode `0700`, files `0600`): - - `ca.key`/`ca.crt` — a local root CA (RSA 4096 or Ed25519, 20 y, `CA:TRUE`); - - `upsd.key`/`upsd.crt` — the exporter's server certificate, signed by the CA - (`CN` + `subjectAltName` for the address the collector dials — - `driveripper.reeselink.com` and `10.1.x.x` per the fleet facts; the SAN list is - a script argument, defaulting to the `NUT_HOST` value); - - `dashboard.key`/`dashboard.crt` — the collector's client certificate (the one the - `NutClient` will present); - - re-runnable: existing material is verified (the certs chain to the CA) and - left alone; `--rotate` regenerates (a rotation re-issues all three — the old - server cert stops verifying, which is the intended eviction, like the - session-secret rotation in phase 11). -2. Verify the upsd-side configuration **on the deployed upsd** (from - `driveripper`, where `sudo` grants the `/etc/ups` reads per the fleet facts): - read the running `upsd`'s TLS capability — `upsd -V`, its conf's current `LISTEN` - lines, and the 2.8.5 documentation *as installed on the host* (`man upsd` / the - package's docs) — and record the exact configuration that serves `STARTTLS` on - 3051 with the server certificate, in `data/logs/NOTES-upsd-tls.md` (the exact - directive name(s), the cert/key paths upsd expects, and whether the 2.8.5 build - verifies *client* certificates at all — the phase-15 ASSUMPTION says it does not; - the note records the evidence either way). If the installed 2.8.5 **cannot** - serve `STARTTLS` (a `--without-tls` build — check `upsd -V`'s feature list), - record that and the phase's task 02/04 narrow to "the client is ready, the - server-side is the operator's upgrade call" — do **not** install a different NUT - (a fleet change is the operator's action, the AGENTS.md fleet-facts doctrine). - - ASSUMPTION: the script uses `openssl` (present on every fleet host, the - `deploy/install-agent.sh` doctrine — stdlib/tooling only, no new package) and - the default `NUT_TLS_DIR` lives in the operator's home config tree, not the - repo (invariant 6: the material is a credential, never tracked). -3. `README.md` → *NUT*: the operator walkthrough — run the script on the machine that - dials upsd, copy `upsd.crt`/`upsd.key` to the exporter, apply the task-1-recorded - upsd configuration (the `sudo` grant for the `/etc/ups` writes exists per the - fleet facts; the write itself stays the operator's action, like every other - `upsd.users` edit in this repo — the known-gap-1 doctrine: never grant `actions = - -`, never write it blind), restart `nut-server`, and confirm the collector - connects (task 04's `live` probe). - -## Testing & Quality - -- Script test (the repo's `tests/test_deploy_scripts.py` device — it already fails - the day a script regresses): the script's output layout (file names, modes - 0700/0600, the certs chain to the CA via `openssl verify`), re-run idempotency, - and `--rotate` changing the key fingerprints; no tracked file receives key material - (the test asserts the generated dir is the only place the material is). -- Coverage: **>90%** on new/modified code (script + its test; the 94% gate must - hold). - -## Completion Criteria - -- [ ] a clean run of the script produces the CA + both certificates with the pinned - layout/modes; `openssl verify -CAfile ca.crt upsd.crt` passes; a re-run is a - no-op; `--rotate` changes fingerprints -- [ ] `data/logs/NOTES-upsd-tls.md` records the deployed upsd 2.8.5's actual TLS - capability and the exact server-side configuration (or the finding that this - build cannot serve `STARTTLS` — the note, not the assumption, decides task - 02's scope) -- [ ] `tests/test_deploy_scripts.py` pin green; `bash .agents/validate.sh` green -- [ ] the README walkthrough exists and names the operator actions that stay the - operator's (the upsd config write, the restart) diff --git a/.agents/phases/todo/15_nut_tls_credentials/02_nutclient_starttls.md b/.agents/phases/todo/15_nut_tls_credentials/02_nutclient_starttls.md deleted file mode 100644 index ecce84e..0000000 --- a/.agents/phases/todo/15_nut_tls_credentials/02_nutclient_starttls.md +++ /dev/null @@ -1,82 +0,0 @@ -# Task 02 — `STARTTLS` in the NutClient: verified server, presented client - -**Phase:** `15_nut_tls_credentials` · **Source:** `TODO.md:6` — "just use the certs as auth rather than a password" (implemented per the phase ASSUMPTION: certs authenticate the transport; the `upsd.users` account still gates the command) - -## Objective - -The `NutClient` upgrades the connection with the verb the server's own `HELP` -advertises (`STARTTLS` — pinned in `tests/test_nut.py`'s verb-list test): after the -upgrade the socket is wrapped in stdlib `ssl` (no new dependency), the exporter's -certificate is verified against the local CA (the collector dials the real upsd, not -a lookalike), the collector's client certificate is presented (the trust slot for a -server that will check it), and **every other byte of the ~10-line protocol is -unchanged** — the `USERNAME`/`PASSWORD` login, the `LIST`/`GET` reads, the -`INSTCMD` write. When `NUT_TLS` is off (the default, and the only state the current -fleet can use — the laptop cannot reach 3051 at all, per the fleet facts), the -client behaves exactly as today: no `STARTTLS` sent, cleartext, every pre-existing -test passing unmodified in intent. - -## Work - -1. `src/dashboards/sources/nut.py` — `NutClient` (the socket half, where the - existing `connect`/`_read_line`/`login`/`instcmd` live): - - a `tls: bool` + `ca_path`/`cert_path`/`key_path` constructor path (built by - `NutMonitor` from the task-03 settings; the client itself stays testable with - explicit paths); - - on connect, when `tls`: send `STARTTLS\n`, read the OK line (the framing the - fake upsd in `tests/test_nut.py` already models for the other verbs — the same - one-line-per-command discipline), then wrap the **existing** socket with - `ssl.SSLContext(verify_mode=CERT_REQUIRED, purpose=server_auth)`: - `load_verify_locations(ca)`, `load_cert_chain(cert, key)` (the client - certificate), `server_hostname` from the dialled host — the wrap happens on the - connected socket, so `_read_line`/the writers are unchanged after the upgrade - (they already read/write `self._sock`); - - when `tls` is off: the constructor path is not even built (no `SSLContext` - object, no file reads — the cleartext path touches no TLS code at runtime); - - the `LOGIN`/`LOGOUT`/`GET`/`LIST`/`INSTCMD` sequence is unchanged in both modes - (the login happens after the upgrade — the password is the first thing that - travels over the encrypted socket, which is the point). - - The scope guard from task 01: if `data/logs/NOTES-upsd-tls.md` recorded that - the deployed 2.8.5 build **cannot** serve `STARTTLS`, this task still ships - the client (the hermetic loopback proves it; the README says the server side is - the operator's upgrade call) — the client capability is independent of one - host's build. -2. `src/dashboards/collectors.py` — `NutMonitor` takes the task-03 settings and - builds the TLS half (one place, the `from_env()` single-source doctrine `nut_sim` - already follows: the assembly and the model cannot disagree about what is - enabled). A TLS failure (refused upgrade, unverified server, expired cert) is an - `UpstreamError` like any other dial failure — the source goes `DOWN` with the - real reason (invariant 4), and the message follows the existing redacted-URL - doctrine (the dialled address redacted, and **no key material** — task 04's - no-leak test pins it). -3. `src/dashboards/sources/nut_sim.py` — no change expected (the simulator is a - labelled fake, invariant 3/its own doctrine); the `SimUps` tests keep passing — - TLS is a transport property the simulator does not model, and that is correct. - -## Testing & Quality - -- Unit: `tests/test_nut.py` — the fake upsd's `STARTTLS` branch (a scripted - loopback `ssl` server, the task-01-generated certificates in a temp dir — the - test generates its own material with the task-01 script, never a tracked - fixture): the upgrade framing (`STARTTLS` → OK → the wrapped socket answers the - same verbs), the `LOGIN` over TLS, a `GET` reading over TLS, and the - `CERT_REQUIRED` rejection: a loopback server with a certificate the local CA did - not issue → the connection is refused with an `UpstreamError` whose message - names the redacted host and **no** key material. -- Regression: `NUT_TLS` off (the default in every pre-existing test) → every - pre-existing `test_nut.py` / `test_collectors.py` / `test_api.py` behaviour - passes unmodified in intent; no `STARTTLS` string ever appears on the cleartext - wire (the fake upsd's scripted log asserts it was never sent). -- Coverage: **>90%** on this task's new/modified code; the 94% gate must hold. - -## Completion Criteria - -- [ ] the loopback-TLS fake upsd: `STARTTLS` framing, verified server, presented - client certificate (the server with `CERT_REQUIRED` sees it), login + read - over the upgraded socket — all green -- [ ] a wrong-CA server is refused with a redacted, key-free `UpstreamError`; the - source state path (task 04's poller test) shows `DOWN` with the reason, last - good payload kept (invariant 4) -- [ ] the cleartext path is byte-identical: the pre-existing suite passes and the - fake upsd records no `STARTTLS` verb in cleartext mode -- [ ] `bash .agents/validate.sh` green diff --git a/.agents/phases/todo/15_nut_tls_credentials/03_config_gates_and_redaction.md b/.agents/phases/todo/15_nut_tls_credentials/03_config_gates_and_redaction.md deleted file mode 100644 index c577a03..0000000 --- a/.agents/phases/todo/15_nut_tls_credentials/03_config_gates_and_redaction.md +++ /dev/null @@ -1,84 +0,0 @@ -# Task 03 — `NUT_TLS*` settings, the registration story, and the no-leak boundary - -**Phase:** `15_nut_tls_credentials` · **Source:** `TODO.md:6` — "the exporter should be installed with password protection and that password must be used to register it with the collector" - -## Objective - -The config surface: `NUT_TLS` (off by default — the current fleet cannot reach upsd -from the laptop at all, and the deployed dashboard reads cleartext today; enabling -is the operator's action per the README walkthrough) and `NUT_TLS_DIR` (the -0600 material tree task 01 lays out). Plus the "registration" half made explicit in -code and docs: the `upsd.users` account password the operator installed on the -exporter is carried in the collector's env (the existing `NUT_USER`/`NUT_PASSWORD` -settings — the `.env.dev` already holds the credentials per known gap 1c) and is -used at `LOGIN` before every `INSTCMD`; TLS changes *how* it travels, never *that* -it is the gate. And invariant 6 extended: a key is a credential, so no key material -may appear in anything that leaves the process (health records, event rows, error -messages, `/api/config`). - -## Work - -1. `src/dashboards/config.py` — append the block (the phase-11 `DASH_*` block is the - pattern; whichever phase runs second appends on top, never restoring the - other's lines): `NUT_TLS` (the `_env()` truthy rule, same as `NUT_SIMULATE`'s - `from_env()` doctrine — one place the rule lives, the settings object carries - the parsed flag) and `NUT_TLS_DIR` (path, default `~/.config/dashboards/nut-tls`, - expanded). The `Target`/redaction half is unchanged: the TLS settings are not - URLs and carry no userinfo. - - ASSUMPTION: `NUT_TLS_DIR` is a *directory* (the script's layout: - `ca.crt`/`dashboard.crt`/`dashboard.key` inside it), not three path variables — - one knob, one layout, the rotation is a re-run of the script (task 01), and a - mistyped path is a clear `UpstreamError` ("TLS material not found in …"), not - three silent defaults. -2. `src/dashboards/sources/nut.py` — the no-leak boundary (invariant 6, the existing - doctrine's exact pattern: `strip_userinfo`/`Target.display_url` for URL tokens): - - every `UpstreamError` message built in the TLS path is checked against a - **key-free list** — the message may name the redacted dial address, the verb, - and a fixed failure kind (`tls: server certificate not signed by the local - CA`, `tls: client refused the certificate`, `tls: material not found in