Skip to content

Commit 84dc84a

Browse files
mschmickingclaude
andauthored
feat(cli): add a read-only doctor command that explains itself (#23)
Co-authored-by: Maurice Schmicking <maurice.schmicking@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0d471e7 commit 84dc84a

10 files changed

Lines changed: 912 additions & 16 deletions

File tree

AGENTS.md

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,27 @@ infinite-loops on connection errors against this server.
116116
- No new npm dependencies without a good reason. Current set: `ws`, `commander`,
117117
`chokidar`, `diff`.
118118

119+
### Commit and PR titles
120+
121+
Conventional commits, enforced on the **PR title** by `.github/workflows/pr-title.yml`
122+
— the title becomes the squashed commit message and release-please derives the next
123+
version from it, so a title that does not parse is a release that silently never
124+
happens.
125+
126+
Type is one of `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`, `ci`,
127+
`chore`, `revert`. A scope is optional, **but any scope used must be one of**:
128+
129+
```
130+
sync auth watch logs json types cli deps docs release main
131+
```
132+
133+
That list is closed on purpose, to keep the vocabulary small enough to mean something.
134+
A new component does **not** earn a new scope — `feat(doctor)` fails the check, and a
135+
new CLI command belongs under `cli`. If a scope genuinely has to be added, add it to
136+
the workflow in the same PR. (`main` is not a component: it is the branch name in
137+
release-please's own `chore(main): release x.y.z` title, and without it the release PR
138+
cannot merge.) Subjects must not end with a full stop.
139+
119140
## Commands
120141

121142
```bash
@@ -134,7 +155,8 @@ Directly tested: `client/socket`, `client/objects`, `client/auth` (HTTP and HTTP
134155
`config`, `credentials`, `sync/compare`, `sync/mapping`, `sync/safe-path` (path
135156
traversal, symlink-file writes, symlinked-directory writes), the `--json` record shapes,
136157
and the commands `pull`, `push`, `status`, `diff` (including `--against`), `watch`,
137-
`logs`, `list`, `start`, `stop`, `restart`, `new`, `rename`, `move`, `remove`, `backup`.
158+
`logs`, `list`, `start`, `stop`, `restart`, `new`, `rename`, `move`, `remove`, `backup`,
159+
`doctor`.
138160
`sync/manifest` and `sync/scan` are covered indirectly by every pull/push test.
139161

140162
`test/cli.test.ts` spawns the built `dist/cli.js` and asserts on real argv handling.
@@ -146,7 +168,8 @@ there, and it needs `dist/` built first.**
146168
`test/fake-server.ts` serves HTTP and websocket on one port, as real Admin does on 8081.
147169
Its `auth` field selects how the HTTP side answers the probes in `client/auth.ts`
148170
(`disabled``GET /login` 404s, `oauth``POST /oauth/token`, `legacy``POST /login`);
149-
`reset()` returns it to `disabled`, so tests that do not care are unaffected.
171+
`reset()` returns it to `disabled`, so tests that do not care are unaffected. Its
172+
`requireCookieOnSocket` flag reproduces the silent-auth failure below.
150173

151174
`pull` also skips-and-continues per script rather than aborting the whole run: one
152175
unwritable script (a bad id, a symlink in the way) is reported and the rest still
@@ -207,6 +230,27 @@ always `!allowSelfSigned` — the value is the user's decision, not a constant,
207230
writing it as one both misreports what the code does and trips CodeQL's
208231
`js/disabling-certificate-validation`.
209232

233+
### Two failures that look like a broken instance and are not
234+
235+
Both cost a session an hour, and `commands/doctor.ts` exists because of them.
236+
237+
1. **An unauthenticated socket is silent, not angry.** Admin accepts the connection,
238+
sends `___ready___`, and then ignores every command. There is no auth error and no
239+
close — requests simply never come back, and the only thing the caller ever sees is
240+
`Request "..." timed out`. The timeout in `client/socket.ts` therefore carries a
241+
hint naming this cause; `test/fake-server.ts` reproduces it via
242+
`requireCookieOnSocket`.
243+
2. **An expired self-signed certificate is harmless here.** With `allowSelfSigned` the
244+
identity check is the pinned fingerprint, not the chain, so `iob-sync` keeps working
245+
after expiry while every other client on that port fails with
246+
`certificate has expired`. `doctor` reports it as OK plus a note. Do not "fix" this
247+
by tightening the TLS path — the pin is the check, and it is stricter than the chain.
248+
249+
**`iob-sync` is a CLI, not a library.** `package.json` exposes `bin` only; there is no
250+
`exports` map and nothing under `src/` is a supported import. Anything constructing
251+
`AdminSocketClient` directly must reproduce `withContext` in `cli.ts` — certificate
252+
check, then cookie, then socket — and the failure mode when it does not is (1) above.
253+
210254
### Two bugs the watch tests caught
211255

212256
Both were live in working code, and both are the kind that only show up under a test
@@ -224,8 +268,6 @@ testable at all, which matters given a regression there means an infinite push l
224268

225269
## Other known gaps
226270

227-
- Self-signed certificates are honoured on the websocket path but not on the HTTP
228-
auth path (would require an `undici` Agent).
229271
- `init --types` writes the ioBroker type definitions, but the download of
230272
`javascript.d.ts` from GitHub has only been exercised against a live network.
231273

@@ -296,3 +338,10 @@ window so it measures the debounce rather than the disk.
296338
If it recurs, that file is the suspect and the fix is more timing margin — **not**
297339
loosening an assertion. The thing being tested is the guard against an infinite push
298340
loop against someone's house.
341+
342+
One of these turned out not to be timing at all. The `--pull` case waited for the file
343+
content to appear and then asserted on the log line, but `applyRemote` writes the file,
344+
saves the manifest and logs **last** — so the assertions could run inside that window.
345+
It now waits for the `pull` line, which is the operation's real completion signal, and
346+
asserts on the file and manifest afterwards. Before assuming load, check what the code
347+
under test does in what order.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ If `logs` shows nothing, that is usually the adapter's own log level rather than
114114
| `types` | Set up editor intellisense (`log`, `schedule`, ...). `--force`, `--offline`. |
115115
| `login` / `logout` | Save or remove the password for this instance. Never stored in the project. |
116116
| `trust` | Accept the instance's current TLS certificate. Only needed after it changes. `--yes` skips the prompt. |
117+
| `doctor` | Check config, certificate, login, connection and a live round-trip, and say which one is wrong. Read-only, never prompts. Run this first when something looks broken. |
117118
| `pull [pattern]` | Download scripts to disk. Never deletes or overwrites local files. |
118119
| `push [pattern]` | Upload locally modified scripts. Never deletes remote objects. |
119120
| `status` | Show what changed, locally and remotely. |

docs/TROUBLESHOOTING.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,43 @@
22

33
Things that look like bugs and are not. See the [README](../README.md) for the overview.
44

5+
**Start with `iob-sync doctor`.** It checks the config, the certificate, the login, the
6+
connection and a live round-trip, and names the one that is wrong — including the two
7+
cases below, which are the ones that reliably send people down the wrong path. It is
8+
read-only and never prompts, so it is safe to run at any time.
9+
10+
## Commands time out, but the connection "works"
11+
12+
Symptom: `___ready___` arrives, the socket reports connected, and then every request
13+
fails with `Request "getObject" timed out after 20000ms.`
14+
15+
The session is not authenticated. ioBroker Admin does not answer an unauthenticated
16+
command with an error — it accepts the connection, sends `___ready___`, and then
17+
ignores the command entirely. There is nothing to find in the log, because nothing
18+
went wrong at the transport layer.
19+
20+
Usually this means the stored password is stale (`iob-sync login` replaces it) or the
21+
session expired during a long-running `watch`. It also happens to anyone driving
22+
`AdminSocketClient` from their own code without an auth cookie: **iob-sync is a CLI,
23+
not a library** — there is no supported import path, and the wiring the commands rely
24+
on (certificate check, then cookie, then socket) lives in `withContext` in
25+
`src/cli.ts`. A client constructed without `cookie`, `allowSelfSigned` and
26+
`certFingerprint` connects perfectly and then does nothing.
27+
28+
## `certificate has expired` from other tools on the same port
29+
30+
`iob-sync` keeps working while every other client refuses to connect. Both are correct.
31+
32+
A home ioBroker signs its own certificate, typically for one year, and nothing renews
33+
it. With `allowSelfSigned` the chain is not what establishes identity here — the pinned
34+
SHA-256 fingerprint is (see [AUTHENTICATION.md](AUTHENTICATION.md)) — and an expired
35+
certificate signs exactly as well as a fresh one. Anything validating the chain the
36+
normal way rejects it.
37+
38+
`iob-sync doctor` reports this as OK with a note rather than as a fault. To make the
39+
other tools happy, regenerate the certificate on the instance and then run
40+
`iob-sync trust` to accept the new fingerprint.
41+
542
## `logs` prints the banner and nothing else
643

744
It is streaming; there is simply nothing to show. Two things surprise people:

src/cli.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { move } from './commands/move';
3434
import { remove } from './commands/remove';
3535
import { login, logout } from './commands/login';
3636
import { trust } from './commands/trust';
37+
import { doctor } from './commands/doctor';
3738
import { setupTypes } from './commands/types';
3839

3940
/**
@@ -292,6 +293,20 @@ program
292293
})();
293294
});
294295

296+
program
297+
.command('doctor')
298+
.description('check that iob-sync can reach and authenticate to the instance')
299+
.option('--timeout <ms>', 'budget for the connect and round-trip probes', '8000')
300+
.action(function (this: Command) {
301+
const opts = this.opts();
302+
return action(async () => {
303+
// Not withContext: that connects first and aborts on the first failure, which
304+
// is precisely the information this command is here to report.
305+
const { root, config } = await loadConfig(resolveCwd());
306+
await doctor(root, config, { timeoutMs: Number(opts.timeout) || undefined }, logger);
307+
})();
308+
});
309+
295310
program
296311
.command('logout')
297312
.description('remove the stored password for this instance')

src/client/socket.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
3232
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
3333
const CLIENT_NAME = 'iobroker-sync';
3434

35+
/**
36+
* Attached to every request timeout, because this is the only thing an unauthenticated
37+
* caller ever sees.
38+
*
39+
* Admin accepts the connection and sends `___ready___` whether or not a session cookie
40+
* came with it, and then simply ignores commands — no auth error, no close. A bare
41+
* "timed out" therefore reads as a slow or broken instance, which sends people
42+
* debugging the wrong thing entirely. Naming the likely cause here costs one line.
43+
*/
44+
const TIMEOUT_HINT =
45+
'A socket that is open but ignores commands is what an unauthenticated or expired ' +
46+
'session looks like — Admin sends no auth error. Run `iob-sync doctor`. If you are ' +
47+
'driving AdminSocketClient yourself, it needs `cookie`, `allowSelfSigned` and ' +
48+
'`certFingerprint` — see `withContext` in src/cli.ts.';
49+
3550
type Frame = [number, number | null, string?, unknown?];
3651

3752
/**
@@ -289,7 +304,12 @@ export class AdminSocketClient implements SocketClient {
289304
return new Promise<T>((resolve, reject) => {
290305
const timer = setTimeout(() => {
291306
this.pending.delete(id);
292-
reject(new UserError(`Request "${command}" timed out after ${this.requestTimeoutMs}ms.`));
307+
reject(
308+
new UserError(
309+
`Request "${command}" timed out after ${this.requestTimeoutMs}ms.`,
310+
TIMEOUT_HINT,
311+
),
312+
);
293313
}, this.requestTimeoutMs);
294314

295315
this.pending.set(id, {

src/client/tls.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,54 @@ function hostAndPort(url: string): { host: string; port: number } {
8585
return { host: parsed.hostname, port: Number(parsed.port) || 443 };
8686
}
8787

88+
/**
89+
* What the server's certificate says about itself.
90+
*
91+
* The fingerprint is the only field the pin depends on. The rest exists for
92+
* `iob-sync doctor`, which has to be able to say *why* a certificate is or is not a
93+
* problem — "expired seven months ago, and that is fine here" is a sentence nobody
94+
* can write from a fingerprint alone.
95+
*/
96+
export interface CertificateInfo {
97+
/** SHA-256 fingerprint, colon-separated uppercase hex. */
98+
fingerprint: string;
99+
/** Distinguished name, flattened for display, e.g. `CN=iobroker`. */
100+
subject: string;
101+
issuer: string;
102+
/** Undefined when the certificate carries a date Node could not parse. */
103+
validFrom?: Date;
104+
validTo?: Date;
105+
}
106+
107+
/** `{ CN: 'iobroker', O: 'ioBroker' }` -> `CN=iobroker, O=ioBroker`. */
108+
function formatDn(dn: tls.PeerCertificate['subject'] | undefined): string {
109+
if (!dn || typeof dn !== 'object') return '';
110+
return Object.entries(dn)
111+
.map(([key, value]) => `${key}=${String(value)}`)
112+
.join(', ');
113+
}
114+
115+
function parseCertDate(raw: string | undefined): Date | undefined {
116+
if (!raw) return undefined;
117+
const parsed = new Date(raw);
118+
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
119+
}
120+
88121
/**
89122
* Opens a TLS connection purely to read the certificate, then closes it.
90123
*
91124
* Nothing is written to the socket. That is the whole point: this runs before the
92125
* password is sent, so a certificate the user ends up rejecting never sees it.
93126
*/
94-
export function probeCertificate(url: string, allowSelfSigned: boolean): Promise<string> {
127+
export async function probeCertificate(url: string, allowSelfSigned: boolean): Promise<string> {
128+
return (await probeCertificateInfo(url, allowSelfSigned)).fingerprint;
129+
}
130+
131+
/** As `probeCertificate`, but keeps the fields the fingerprint alone cannot explain. */
132+
export function probeCertificateInfo(
133+
url: string,
134+
allowSelfSigned: boolean,
135+
): Promise<CertificateInfo> {
95136
return new Promise((resolve, reject) => {
96137
let target: { host: string; port: number };
97138
try {
@@ -104,11 +145,11 @@ export function probeCertificate(url: string, allowSelfSigned: boolean): Promise
104145
// The handshake, the timeout and the error handler all race to end this; whichever
105146
// gets there first closes the socket and the rest become no-ops.
106147
let settled = false;
107-
const succeed = (fingerprint: string): void => {
148+
const succeed = (info: CertificateInfo): void => {
108149
if (settled) return;
109150
settled = true;
110151
socket.destroy();
111-
resolve(fingerprint);
152+
resolve(info);
112153
};
113154
const fail = (err: Error): void => {
114155
if (settled) return;
@@ -138,7 +179,13 @@ export function probeCertificate(url: string, allowSelfSigned: boolean): Promise
138179
);
139180
return;
140181
}
141-
succeed(fingerprint.toUpperCase());
182+
succeed({
183+
fingerprint: fingerprint.toUpperCase(),
184+
subject: formatDn(cert.subject),
185+
issuer: formatDn(cert.issuer),
186+
validFrom: parseCertDate(cert.valid_from),
187+
validTo: parseCertDate(cert.valid_to),
188+
});
142189
},
143190
);
144191

0 commit comments

Comments
 (0)