re-add privileged actions to todo so we can re-plan the phase
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 <dir>`)
|
||||
— nothing interpolated from the key files themselves (they are opened for the
|
||||
`SSLContext` and never read into a string; the one place a path may appear is
|
||||
`NUT_TLS_DIR`'s *path*, which is not secret material);
|
||||
- the `tests/test_auth.py` device for fixed-text raise/log sites (the structural
|
||||
pin that fails the day someone interpolates a credential) is extended to the
|
||||
`nut.py` TLS error-construction sites — the same structural test shape,
|
||||
applied to the new code.
|
||||
3. `.env.example` / `.env.dev.example` — the `NUT_TLS*` block (commented, off, the
|
||||
walkthrough pointer to the README's NUT section); the existing
|
||||
`NUT_USER`/`NUT_PASSWORD` comments gain the one line that names the registration
|
||||
story: "this is the account the exporter was installed with — it is used at
|
||||
`LOGIN` before every `INSTCMD`; with `NUT_TLS=1` it travels over the encrypted
|
||||
socket, without it the wire is cleartext (the firewall, not the password, is
|
||||
what keeps strangers off 3051 today — the fleet-facts doctrine: the gate is the
|
||||
account + the four gates on `POST /api/ups/command`, and `NUT_ALLOW_ACTIONS`
|
||||
stays the first of them)."
|
||||
4. `README.md` → *NUT*: the settings table rows + the one-paragraph "how the
|
||||
privileged action is protected" that states the whole story in operator words:
|
||||
the account gates the command (the upsd side), the four gates gate the app side
|
||||
(`NUT_ALLOW_ACTIONS`, the account actually having the command, the UI
|
||||
confirmation, the on-battery refusal), TLS (when enabled) keeps the password and
|
||||
the command off the cleartext wire and authenticates both ends by certificate —
|
||||
and says *why the password remains the gate* (the NUT 2.8.5 constraint, the
|
||||
phase ASSUMPTION, with the task-01 note as the evidence).
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Unit: `tests/test_config.py` — the `NUT_TLS` truthy rule (the `NUT_SIMULATE`
|
||||
test's shape), the `NUT_TLS_DIR` default/expansion, and the "unset means
|
||||
cleartext" default (an app built with no TLS settings never opens a key file —
|
||||
a `monkeypatch` on the file reads asserts zero opens in the default suite).
|
||||
- Unit: the structural no-leak pin (the `test_auth.py` device, task-02's error
|
||||
sites) — a regression that interpolates a key file's *contents* into a message
|
||||
fails the test, not a log line.
|
||||
- Coverage: **>90%** on new/modified code; the 94% gate must hold.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `NUT_TLS` unset (the default in every pre-existing test and in both env
|
||||
templates): no key file is opened, no `STARTTLS` is sent — the pre-existing
|
||||
suite is byte-identical in behaviour
|
||||
- [ ] `NUT_TLS=1` + the task-01 layout: the settings resolve to the three files;
|
||||
a missing file is a clear `UpstreamError` naming the directory, nothing more
|
||||
- [ ] the env templates + README carry the registration story and the NUT 2.8.5
|
||||
"why the password remains the gate" paragraph
|
||||
- [ ] `bash .agents/validate.sh` green
|
||||
@@ -1,85 +0,0 @@
|
||||
# Task 04 — The loopback-TLS suite, the no-leak pin, and the read-only live probe
|
||||
|
||||
**Phase:** `15_nut_tls_credentials` · **Source:** `TODO.md:6` — "For privileged actions (UPS commands) the exporter should be installed with password protection…" (the whole phase, proven)
|
||||
|
||||
## Objective
|
||||
|
||||
The proof layer: a hermetic loopback TLS fake upsd (stdlib `ssl`, the task-01
|
||||
script's certificates in a temp dir — never tracked), the `STARTTLS` framing pin
|
||||
that fails the day the server-side verb stops being answered (the exact device
|
||||
`test_login_uses_the_verbs_upsd_2_8_5_answers_to` uses for the `USER`/`PASS`
|
||||
regression), the wrong-CA rejection, the client-certificate presentation, the
|
||||
`LOGIN` gate **over TLS**, the no-leak pin for key material, and the read-only
|
||||
`live` probe of the deployed upsd's `STARTTLS` answer. No `INSTCMD` from any test
|
||||
(a battery test on hardware something is plugged into is not a test — the
|
||||
AGENTS.md live rule).
|
||||
|
||||
## Work
|
||||
|
||||
1. `tests/test_nut.py` — the fake upsd's TLS half (the existing fake's scripted-
|
||||
reply doctrine, one socket upgrade away):
|
||||
- a loopback `ssl.SSLContext` server (the task-01 `upsd.crt`/`upsd.key`,
|
||||
`CERT_REQUIRED` + `load_verify_locations(dashboard.crt)` so it **demands** the
|
||||
client certificate) answering the same one-line-per-command script: `STARTTLS`
|
||||
→ OK, then the post-upgrade verbs;
|
||||
- `test_starttls_upgrade_framing` — the client sends `STARTTLS` exactly once,
|
||||
before the `LOGIN`, and the post-upgrade `LIST`/`GET` answers are parsed
|
||||
identically to the cleartext suite (the `UpsReading` contract,
|
||||
`tests/test_ups_contract.py`, holds over TLS);
|
||||
- `test_client_certificate_is_presented` — the server context's
|
||||
`CERT_REQUIRED` passes only because the client presented `dashboard.crt`
|
||||
(the test asserts the server saw a verified peer cert, not just a completed
|
||||
handshake);
|
||||
- `test_unverified_server_is_refused` — a second loopback server with a
|
||||
self-signed certificate the local CA did not issue → `UpstreamError`, the
|
||||
message redacted and key-free (asserted against the no-leak list), the
|
||||
connection closed, no `LOGIN` sent (the password never travelled);
|
||||
- `test_login_gate_over_tls` — a wrong `PASSWORD` over the upgraded socket
|
||||
still answers the upsd's `ERR` exactly as the cleartext suite pins it (the
|
||||
account gate is TLS-agnostic — the phase ASSUMPTION's core, proven).
|
||||
2. `tests/test_collectors.py` — the poller half (invariant 4's exact doctrine, the
|
||||
existing `store-error`/stale device): a `NutMonitor` whose loopback server
|
||||
dies mid-session (the TLS handshake succeeds, then the socket closes) → the
|
||||
source is `DOWN` with the real reason, the last good payload kept, one
|
||||
transition event, no exception ends the task; a TLS-missing-material start
|
||||
(task 03's `NUT_TLS_DIR` with no files) → the same `DOWN` path with the clear
|
||||
reason, **without dialling** (invariant 3's shape: a source that cannot even
|
||||
build its material does not pretend to be configured-and-dead — the event says
|
||||
the material is missing, once, not per poll).
|
||||
3. `tests/test_api.py` — the no-leak pin at the boundary that leaves the process:
|
||||
with a TLS failure injected, `/api/health`'s job record, the `event` table row
|
||||
and `/api/events`' answer contain none of the key material (the temp-dir
|
||||
`dashboard.key`'s contents, the `ca.key`'s contents, and any PEM marker are all
|
||||
absent) — the same test shape that pins the URL-token redaction, applied to
|
||||
key material.
|
||||
4. `tests/test_live_fleet.py` — the `live` probe (skipped unless `--live` and
|
||||
`NUT_TLS`-capable material exists; **read-only**): the deployed upsd's
|
||||
`STARTTLS` answer — `HELLO`-style: open a connection, send `STARTTLS`, record
|
||||
the one-line answer (OK or the 2.8.5 build's refusal — both are findings for
|
||||
`data/logs/NOTES-upsd-tls.md`'s "what the live server does" line, the task-01
|
||||
note's running update), **no `LOGIN` and no `INSTCMD`** (the account password
|
||||
is not a test input; the known-gap-1 doctrine: a live test never writes).
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Unit: per the work list — framing, presentation, refusal, the gate over TLS,
|
||||
the poller's `DOWN` path, the no-leak pin.
|
||||
- Live: the read-only probe, its answer recorded (the tally in the completion
|
||||
note, the known transport gap acknowledged: from the laptop the probe skips on
|
||||
the 3051 unreachability, per the fleet facts).
|
||||
- Coverage: **>90%** on new/modified code; the 94% gate must hold.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `uv run pytest tests/test_nut.py tests/test_collectors.py tests/test_api.py`
|
||||
green — the whole task-02 matrix (framing/presentation/refusal/gate) plus
|
||||
the poller `DOWN` path plus the no-leak pin
|
||||
- [ ] no `INSTCMD` string in any `live` test (the suite's read-only rule, greppable
|
||||
and pinned by the `test_live_fleet.py` convention the existing live tests
|
||||
follow)
|
||||
- [ ] `uv run pytest -m live --live` run (skips recorded for the unreachable
|
||||
paths); the `STARTTLS` live answer recorded in `data/logs/NOTES-upsd-tls.md`
|
||||
when reachable
|
||||
- [ ] `bash .agents/validate.sh` green — the phase's final pass: the whole suite
|
||||
100% (the phase-11 completion doctrine: "Complete" only when the entire
|
||||
project test suite passes, including the opt-in live run)
|
||||
@@ -1 +1,3 @@
|
||||
# TODO
|
||||
|
||||
- [ ] 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.
|
||||
|
||||
Reference in New Issue
Block a user