Skip to content

fix(cli): explain a control-service transport failure instead of quoting rustls - #28

Merged
damonleelcx merged 1 commit into
develop-v1.0.5from
fix/control-plane-transport-error-message
Aug 22, 2026
Merged

fix(cli): explain a control-service transport failure instead of quoting rustls#28
damonleelcx merged 1 commit into
develop-v1.0.5from
fix/control-plane-transport-error-message

Conversation

@damonleelcx

Copy link
Copy Markdown

A user pointing aikey login at a plaintext control service on port 3000 got this:

Error: Login failed: login init failed: https://120.24.220.105:3000/v1/auth/cli/login/init:
Connection Failed: tls connection init failed: received corrupt message of type InvalidContentType

That names no cause the user can act on, yet the cause was simple and certain: the URL said https:// and the server answered in plain HTTP, so rustls read an HTTP response where a TLS record should have been. The user could not know that. The CLI could.

Not just login. All 12 control-plane calls in platform_client end in the same format!("<op> failed: {}", e), so every one of them rendered the same unreadable chain — aikey list, sync, claim, key delivery, token refresh.

After

Error: Login failed: login init failed: cannot reach the control service at https://120.24.220.105:3000

  Cause:    https://120.24.220.105:3000 uses https://, but the server answered in plain HTTP — it is not serving TLS on that port.
  Evidence: https://120.24.220.105:3000/v1/auth/cli/login/init: Connection Failed: tls connection init failed: received corrupt message of type InvalidContentType — the TLS handshake failed because the reply was not a TLS record at all.

  To fix:
    1. If the server is meant to be plain HTTP, re-run login with the http:// form:
       aikey login --control-url http://120.24.220.105:3000
    2. If it is meant to serve HTTPS, terminate TLS on that port (nginx / ingress / load balancer) and keep the https:// URL.

Design

Enrich, never replace. An unrecognised failure is returned exactly as ureq rendered it — as loud and as complete as today. HTTP status errors return early: a business outcome is not ours to interpret. Evidence quotes the original so the user can check the reasoning instead of trusting it.

An ordered table, not a chain of heuristics. Each row carries the kinds it may be concluded from as well as its text signatures — because matching on text alone misdiagnoses. A corporate-proxy failure (ErrorKind::ProxyConnect) also says "connection refused", and concluding "nothing is listening on the control URL port" from it sends the user to inspect the wrong machine. errno is matched numerically (61 / 111 / 10061) because std::io::Error's Display is localised on Windows.

Covered: plaintext server, hostname mismatch, expired cert, untrusted cert, connection refused, timeout, DNS. The untrusted-cert advice deliberately never offers a verification bypass, and a fence asserts that.

Pure classifier + thin adapter. ureq's error constructors are pub(crate), so a test cannot fabricate a ureq::Error. Classification therefore takes three primitives (url, kind, full error text) and is directly unit-testable; the adapter is covered end-to-end against real local sockets — one listener speaking plain HTTP reproduces the reported failure exactly, another (closed port) reproduces the refusal. Without those, the table would be a fence over an input shape nobody has confirmed ureq produces.

Not covered, deliberately

The mirror case (http:// against a TLS-only port). Measured against a live TLS endpoint: the server answers HTTP/1.1 400 Bad Request in plaintext, so it surfaces as ureq::Error::Status(400) with server-specific body text, never as a transport failure. Diagnosing it belongs to the status layer and would have to guess at nginx/cloudflare wording — left alone rather than guessed at.

Verification

  • cargo test --lib on this PR's base → 1313 passed, 0 failed; cargo clippy --lib clean for these files.
  • 15 new tests. Mutation-verified — removing the plaintext signature, deleting the specific certificate rows, dropping the kinds column, and making to_http stop rewriting the scheme each turn the intended assertions red, and each mutation compiles (a mutation that fails to build proves nothing).
  • End-to-end against a real plaintext control service: produced the output above. A failed login does not write config.json, so reproducing this does not disturb an existing setup.

One test that is NOT a fence, labelled as such in the source

explain_never_touches_an_http_status_error cannot go red: routing Error::Status through the classifier instead of returning early produces identical output, because TransportKind::Other matches no table row. The early return is structural; the kinds column is what actually prevents misdiagnosis, and that one does go red. Recorded in the source so a future reader does not mistake the green for proof.

Open question for the reviewer

These diagnostics carry no error code. platform_client is Result<_, String> end to end and has no codes today; wiring them into error_codes.rs means changing 12 signatures and every caller, and widening a stable public surface. Three options are laid out at the end of the bugfix doc — this PR takes the first (keep prose, no code) and does not treat that as settled.

Bugfix: workflow/CI/bugfix/20260821-cli-control-plane-transport-error-unactionable.md

🤖 Generated with Claude Code

…ing rustls

A user pointing `aikey login` at a plaintext control service on port 3000
got this:

    Error: Login failed: login init failed: https://120.24.220.105:3000/...:
    Connection Failed: tls connection init failed: received corrupt message
    of type InvalidContentType

That text names no cause the user can act on, yet the cause was simple and
certain: the URL said https:// and the server answered in plain HTTP, so
rustls read an HTTP response where a TLS record should have been. The user
could not know that. The CLI could.

Not just login — all 12 control-plane calls in `platform_client` end in the
same `format!("<op> failed: {}", e)`, so every one of them rendered the same
unreadable chain.

`control_plane_error::explain` ENRICHES a failure it recognises and returns
the raw text UNCHANGED otherwise. It never replaces, summarises or swallows
the original: an unrecognised failure has to stay exactly as loud and as
complete as it is today. HTTP status errors return early — a business
outcome is not ours to interpret.

The recognised set is an ordered table, not a chain of heuristics. Each row
carries the kinds it may be concluded from as well as its text signatures,
because matching on text alone misdiagnoses: a corporate-proxy failure
(`ErrorKind::ProxyConnect`) also says "connection refused", and concluding
"nothing is listening on the control URL port" from it points the user at
the wrong machine entirely. errno is matched numerically (61/111/10061)
because `std::io::Error`'s Display is localised on Windows.

The classifier is pure — url, kind, full error text — because ureq's error
constructors are `pub(crate)` and a test cannot fabricate a `ureq::Error`.
The adapter is covered end-to-end against REAL local sockets instead: one
listener that speaks plain HTTP reproduces the reported failure exactly, and
a closed port reproduces the refusal. Without those, the table would be a
fence over an input shape nobody has confirmed ureq produces.

NOT COVERED, deliberately: the mirror case (http:// against a TLS-only
port). Measured against a live TLS endpoint — the server answers
`HTTP/1.1 400 Bad Request` in plaintext, so it surfaces as
`ureq::Error::Status(400)` with server-specific body text, never as a
transport failure. Diagnosing it belongs to the status layer and would have
to guess at nginx/cloudflare wording, so it is left alone rather than
guessed at.

Bugfix: workflow/CI/bugfix/20260821-cli-control-plane-transport-error-unactionable.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit dc495fd31edbc5689190be8a4101a934a1143044)
@damonleelcx
damonleelcx merged commit 706a4eb into develop-v1.0.5 Aug 22, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant