Skip to content

feat(cli): add a read-only doctor command that explains itself - #23

Merged
mschmicking merged 3 commits into
mainfrom
feat/doctor-command
Aug 5, 2026
Merged

feat(cli): add a read-only doctor command that explains itself#23
mschmicking merged 3 commits into
mainfrom
feat/doctor-command

Conversation

@mschmicking

Copy link
Copy Markdown
Owner

Why

Another session working against the live instance lost an hour to a false alarm. Nothing was broken — list and status both worked, IN-SYNC: 10 — but two things made that impossible to establish from the outside:

  1. An unauthenticated socket is silent, not angry. Admin accepts the connection, sends ___ready___, then ignores every command. No auth error, no close. The only symptom is Request "getObject" timed out after 20000ms., which reads as a slow or broken instance.
  2. The Admin TLS certificate expired 2026-01-09 (CN=iobroker, valid Jan 2025 – Jan 2026). iob-sync is genuinely unaffected: with allowSelfSigned the identity check is the pinned SHA-256 fingerprint, not the chain. Every other client on :8081 fails with certificate has expired, so the instance looks dead from anywhere else.

Neither fact is discoverable from the outside, and whoever hits them may have nothing but the binary in front of them — no repository, no docs. So the binary has to be the thing that says it. Docs alone would not have helped here.

What

iob-sync doctor — checks config, certificate, login, socket and a live round-trip, and names the step that is wrong.

config      OK    …/iobroker-scripts (https://…:8081, scripts/, as admin)
tls         OK    pinned fingerprint matches — certificate expired 2026-01-09, see note
auth        OK    authenticated via OAuth2 (/oauth/token), password from store
socket      OK    connected, ___ready___ received
round-trip  OK    getObject answered in 101ms — the session is authenticated
scripts     OK    10 remote, 10 local, 10 tracked in the manifest

  - tls: The instance's TLS certificate (CN=iobroker, O=ioBroker GmbH, OU=iobroker)
    expired on 2026-01-09. iob-sync is unaffected by this: with allowSelfSigned the
    identity check is the pinned SHA-256 fingerprint, not the certificate chain, and
    an expired certificate signs exactly as well as a fresh one. Other clients talking
    to this instance will reject it until it is renewed.

OK with notes.
  • The round-trip is the point. Every check above it succeeds just as happily without a session cookie; it is the only one that distinguishes "connected" from "authenticated".
  • Strictly read-only. Never prompts, never writes to the server, and deliberately will not pin a certificate on first use — a diagnostic that changes what it diagnoses cannot tell you where you stand. Covered by a test.
  • Not routed through withContext, which aborts at the first failing step; which step fails is the whole answer.
  • Emits NDJSON under the existing global --json; exits non-zero only on a real failure. Verdict is one of three greppable words.

Alongside it, the request timeout in client/socket.ts now carries a hint naming the silent-auth cause, this command, and the three options AdminSocketClient needs. That string is what someone driving the client directly actually reads — and it also helps an ordinary user whose session expires mid-watch.

Notes for review

  • probeCertificate is now a thin wrapper over probeCertificateInfo, which additionally returns subject, issuer and the validity window. Existing call sites and their tests are untouched.
  • The certificate verdicts are unit-tested through describeCertificate rather than a real handshake — the interesting cases involve dates in the past, and minting an already-expired certificate per run buys nothing over passing one in.
  • test/fake-server.ts gains requireCookieOnSocket, which reproduces the silent failure exactly: accept, ___ready___, then drop every command frame.
  • Removes a stale AGENTS.md gap claiming self-signed certificates are not honoured on the HTTP auth path. client/auth.ts fixed that, and the line was actively misleading — plausibly part of what sent the other session the wrong way.
  • The separate test(watch) commit fixes a genuine ordering race, not a timing one: the --pull case asserted on a log line that applyRemote emits after the file and manifest it was waiting on. No assertion was loosened.

Verification

  • Full suite: 274 pass, 0 fail, three consecutive clean runs. Lint, format check and both typechecks clean.
  • Run against the real instance, read-only: output above; .iobroker-sync.json mtime unchanged after two runs, confirming nothing was pinned or written.

🤖 Generated with Claude Code

mschmicking and others added 2 commits August 5, 2026 10:25
The `--pull` case waited for the pulled content to appear on disk and then
asserted that a `pull` line had been logged. `applyRemote` writes the file,
saves the manifest and logs last, so the assertions could run inside that
window — reliably, once the suite got busy enough.

It now waits for the log line, which is the operation's real completion
signal, and asserts on the file and the manifest afterwards. Every assertion
is kept; this is a stronger wait, not a looser check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two failure modes on a live instance look like a broken instance and are not,
and both cost a session an hour of false debugging.

An unauthenticated socket is silent rather than angry: Admin accepts the
connection, sends ___ready___, and then ignores every command. There is no
auth error and no close, so the only symptom is a request timing out — which
reads as a slow or broken server.

And a self-signed certificate that has expired is harmless here: with
allowSelfSigned the identity check is the pinned SHA-256 fingerprint, not the
chain. iob-sync keeps working while every other client on the port fails with
`certificate has expired`.

Neither fact is discoverable from the outside, and anyone hitting them may
have nothing but the binary in front of them — no repository, no docs. So the
binary has to be the thing that says it.

`iob-sync doctor` checks config, certificate, login, socket and a live
round-trip, and reports which step is wrong. The round-trip is the point: it
is the only check that distinguishes "connected" from "authenticated".
Strictly read-only — it never prompts, never writes to the server, and in
particular will not pin a certificate on first use, because a diagnostic that
changes what it diagnoses cannot tell you where you stand. It emits NDJSON
under the existing --json and exits non-zero only on a real failure.

The request timeout in client/socket.ts now carries a hint naming the silent
auth cause, this command, and the three options AdminSocketClient needs — that
string is what someone driving the client directly actually reads.

Also removes a stale AGENTS.md gap claiming self-signed certificates are not
honoured on the HTTP auth path. client/auth.ts fixed that, and the line was
actively misleading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mschmicking mschmicking changed the title feat(doctor): add a read-only health check that explains itself feat(cli): add a read-only doctor command that explains itself Aug 5, 2026
`feat(doctor)` failed the PR-title check: the scope allowlist in
.github/workflows/pr-title.yml is closed, and a new component does not earn a
new scope. Nothing in AGENTS.md said so, and the workflow is not somewhere you
look before naming a branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mschmicking
mschmicking merged commit 84dc84a into main Aug 5, 2026
7 checks passed
@mschmicking
mschmicking deleted the feat/doctor-command branch August 5, 2026 08:58
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