@tikoci/quickchr 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -154,10 +154,11 @@ with a layered design:
154
154
  dispatches. `test-filter` narrows; `run-examples` defaults ON. Each runner boots its
155
155
  native CHR arch — `detectAccel()` picks KVM/HVF/TCG. Agents dispatch this to ground
156
156
  platform hypotheses without waiting for a PR cycle.
157
- - **`release.yml`** — one-click release: freshness gate (no suite re-run — main is kept
158
- continuously release-able) + non-empty CHANGELOG `[Unreleased]` →
159
- `scripts/release-prep.ts` bump/rollover → tag + GitHub Release + `npm publish
160
- --provenance` (odd minor → `next`, even → `latest`).
157
+ - **`release.yml`** — one-click publish from committed release state: freshness gate
158
+ (no suite re-run — main is kept continuously release-able) + `package.json` version
159
+ with a matching non-empty CHANGELOG section → tag + GitHub Release + `npm publish
160
+ --provenance` (odd minor → `next`, even → `latest`). CI never bumps versions or pushes
161
+ to protected `main`.
161
162
  - **`ros-versions.yml`** — daily: new RouterOS versions (per channel) with no linux-x86
162
163
  record in `ci-data/tested-versions.json` ride the `routeros-targets` matrix of a
163
164
  single integration dispatch.
@@ -336,7 +337,7 @@ the CLI/library surface is tracked in
336
337
 
337
338
  ### Agent-Friendliness — Discoverability Over Features
338
339
 
339
- A recurring lesson from downstream agents (donny, centrs, restraml): the friction is usually **finding** an existing capability, not a missing one. Issue #18 (centrs) nearly rebuilt UDP forwarding, `socket-connect` L2, and the guest→host gateway — all of which already existed — because the only way to choose among them was reading `src/lib/network.ts`. Rule: **every CLI-documented capability also needs a library-facing, by-goal surface** — JSDoc at the call site, a by-goal recipe (`docs/networking-recipes.md`), and coverage in the `routeros-quickchr` skill — or agents won't find it. Connection handoff for harnesses goes through `ChrInstance.descriptor()` / `quickchr inspect` / `quickchr env` (credential-bearing by design), never by reading `machine.json`.
340
+ A recurring lesson from downstream agents (donny, centrs, restraml): the friction is usually **finding** an existing capability, not a missing one. Issue #18 (centrs) nearly rebuilt UDP forwarding, `socket-connect` L2, and the guest→host gateway — all of which already existed — because the only way to choose among them was reading `src/lib/network.ts`. Rule: **every CLI-documented capability also needs a library-facing, by-goal surface** — JSDoc at the call site, a by-goal recipe (`docs/networking-recipes.md`), and coverage in the `routeros-quickchr` skill — or agents won't find it. Connection handoff for harnesses goes through `ChrInstance.descriptor()` / `quickchr inspect` / `quickchr env` (credential-bearing by design), never by reading `machine.json`. The structured, versioned **descriptor v1** contract that centrs consumes for `--quickchr` targets — its shape, per-service semantics, scope boundaries, and staged implementation plan — is specified in [`docs/centrs-interface.md`](./docs/centrs-interface.md) (issue [#71](https://github.com/tikoci/quickchr/issues/71)).
340
341
 
341
342
  ### Out of Scope (decided)
342
343
 
package/MANUAL.md CHANGED
@@ -218,10 +218,16 @@ Live REST query. Hits the running CHR and returns the requested group
218
218
 
219
219
  #### `inspect <name> [--json]`
220
220
 
221
- Stable machine-readable connection descriptor for a **running** CHR. The
222
- descriptor includes status, ports, original port mappings, URLs, auth, env
223
- vars, and the machine directory. `--json` is accepted for symmetry with other
224
- read commands; output is always JSON.
221
+ Stable, versioned (`descriptorVersion: 1`) connection descriptor for a
222
+ **running** CHR — the quickchr ↔ centrs interface contract (issue #71,
223
+ [`docs/centrs-interface.md`](docs/centrs-interface.md)). Machine identity
224
+ (name, version, arch, cpu, mem, pid, machineDir, timestamps) plus a
225
+ per-service `services` map (`rest-api`, `native-api`, `ssh`), each entry
226
+ giving its host, port, transport, `tls` flag, availability, and auth; plus
227
+ optional `customForwards` (e.g. `winbox`) and topology-only `networks`.
228
+ `--json` is accepted for symmetry with other read commands; output is
229
+ always JSON. Does not include subprocess env vars — use `quickchr env` for
230
+ those.
225
231
 
226
232
  Stopped machines fail with `MACHINE_STOPPED` instead of returning stale
227
233
  connection details. Use `quickchr start <name>` first.
@@ -523,7 +529,7 @@ interface ChrInstance {
523
529
 
524
530
  queryLoad(): Promise<ChrLoadSample | null>;
525
531
  subprocessEnv(): Promise<Record<string, string>>;
526
- descriptor(): Promise<MachineDescriptor>;
532
+ descriptor(): Promise<Descriptor>;
527
533
  }
528
534
  ```
529
535
 
@@ -536,15 +542,21 @@ transiently resets connections post-boot/reboot). HTTP errors and
536
542
  `QUICKCHR_AUTH`, plus legacy `URLBASE`/`BASICAUTH`) so child processes
537
543
  can hit the CHR without re-resolving auth.
538
544
 
539
- `descriptor()` returns the stable JSON shape used by `quickchr inspect`:
540
- machine identity, status, ports, port mappings, URLs, auth, env, and
541
- timestamps. It is intentionally **running-only** and throws
542
- `MACHINE_STOPPED` for stopped machines.
543
-
544
- Both `subprocessEnv()` and `descriptor()` expose auth material by design
545
- (`QUICKCHR_AUTH`, `BASICAUTH`, `auth.password`, `auth.basic`,
546
- `auth.header`). Treat their output as credentials and redact before
547
- logging or attaching to bug reports.
545
+ `descriptor()` returns the stable, versioned JSON shape used by `quickchr
546
+ inspect` — the quickchr ↔ centrs interface contract (issue #71,
547
+ [`docs/centrs-interface.md`](docs/centrs-interface.md)): machine identity,
548
+ status, a per-service `services` map (`rest-api`, `native-api`, `ssh` — each
549
+ with host/port/transport/`tls`/availability/auth), optional
550
+ `customForwards`/`networks`, and timestamps. It is intentionally
551
+ **running-only** and throws `MACHINE_STOPPED` for stopped machines. It has
552
+ no `env` field — use `subprocessEnv()` for env-var-shaped connection facts.
553
+
554
+ Both `subprocessEnv()` and `descriptor()` expose auth material by design —
555
+ `subprocessEnv()`'s `QUICKCHR_AUTH`/`BASICAUTH` env vars, and `descriptor()`'s
556
+ own per-service auth fields (`services["rest-api"].auth.password`/`.basic`/
557
+ `.header`; `services["native-api"].auth.password`, no basic/header;
558
+ `services.ssh.auth.privateKeyPath`). Treat their output as credentials and
559
+ redact before logging or attaching to bug reports.
548
560
 
549
561
  ### `StartOptions` (selected)
550
562
 
package/README.md CHANGED
@@ -302,20 +302,30 @@ host port for the machine. Existing machines keep the mapping stored in
302
302
 
303
303
  ### Machine Descriptors and Subprocess Environments
304
304
 
305
- `quickchr inspect <name> [--json]` emits a stable JSON descriptor for a
306
- **running** machine: status, ports, URLs, auth, env vars, and the machine
307
- directory. `--json` is accepted for parity; inspect output is always JSON.
305
+ `quickchr inspect <name> [--json]` emits a stable, versioned
306
+ (`descriptorVersion: 1`) JSON descriptor for a **running** machine — the
307
+ quickchr ↔ centrs interface contract
308
+ ([issue #71](https://github.com/tikoci/quickchr/issues/71),
309
+ [`docs/centrs-interface.md`](docs/centrs-interface.md)): status, machine
310
+ identity, and a per-service `services` map (`rest-api`, `native-api`, `ssh`)
311
+ giving host/port/transport/`tls`/availability/auth for each, plus optional
312
+ `customForwards` and `networks`. `--json` is accepted for parity; inspect
313
+ output is always JSON. It has no `env` field — use `quickchr env` for
314
+ subprocess env vars.
308
315
 
309
316
  `quickchr env <name> [--json]` prints the same subprocess environment as
310
317
  `ChrInstance.subprocessEnv()`: shell `KEY=value` lines by default, or a JSON
311
318
  map with `--json`.
312
319
 
313
- > **Credential caveat:** descriptor/env output includes connection secrets
314
- > (`auth.password`, `auth.basic`, `auth.header`, `QUICKCHR_AUTH`, `BASICAUTH`)
315
- > so child processes can connect without reading quickchr's secret store. Treat
316
- > it like a password: do not commit it, paste it into public issues, or leave it
317
- > in CI logs. Stopped machines fail with `MACHINE_STOPPED`; start the machine
318
- > before requesting a descriptor or env map.
320
+ > **Credential caveat:** descriptor/env output includes connection secrets —
321
+ > `subprocessEnv()`/`quickchr env`'s `QUICKCHR_AUTH`/`BASICAUTH`, and each
322
+ > `descriptor()` service's own auth fields (`services["rest-api"].auth.password`/
323
+ > `.basic`/`.header`; `services["native-api"].auth.password`, no basic/header;
324
+ > `services.ssh.auth.privateKeyPath`) — so child processes can connect without
325
+ > reading quickchr's secret store. Treat it like a password: do not commit it,
326
+ > paste it into public issues, or leave it in CI logs. Stopped machines fail
327
+ > with `MACHINE_STOPPED`; start the machine before requesting a descriptor or
328
+ > env map.
319
329
 
320
330
  ### Library Usage
321
331
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/quickchr",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU",
5
5
  "module": "src/index.ts",
6
6
  "main": "src/index.ts",
package/src/cli/index.ts CHANGED
@@ -1490,7 +1490,7 @@ function shellQuoteEnv(value: string): string {
1490
1490
  return `'${value.replaceAll("'", "'\\''")}'`;
1491
1491
  }
1492
1492
 
1493
- async function getRunningDescriptor(name: string) {
1493
+ async function getRunningInstance(name: string) {
1494
1494
  const { QuickCHR } = await import("../lib/quickchr.ts");
1495
1495
  const { machineNotFoundMessage } = await import("./format.ts");
1496
1496
  const instance = QuickCHR.get(name);
@@ -1498,6 +1498,18 @@ async function getRunningDescriptor(name: string) {
1498
1498
  console.error(machineNotFoundMessage(name));
1499
1499
  process.exit(1);
1500
1500
  }
1501
+ if (instance.state.status !== "running") {
1502
+ const { QuickCHRError } = await import("../lib/types.ts");
1503
+ throw new QuickCHRError(
1504
+ "MACHINE_STOPPED",
1505
+ `Machine "${name}" must be running to inspect its connection environment (current status: ${instance.state.status}).`,
1506
+ );
1507
+ }
1508
+ return instance;
1509
+ }
1510
+
1511
+ async function getRunningDescriptor(name: string) {
1512
+ const instance = await getRunningInstance(name);
1501
1513
  return instance.descriptor();
1502
1514
  }
1503
1515
 
@@ -1520,12 +1532,13 @@ async function cmdEnv(argv: string[] = []) {
1520
1532
  console.error("Usage: quickchr env <name> [--json]");
1521
1533
  process.exit(1);
1522
1534
  }
1523
- const descriptor = await getRunningDescriptor(name);
1535
+ const instance = await getRunningInstance(name);
1536
+ const env = await instance.subprocessEnv();
1524
1537
  if (asJson) {
1525
- console.log(JSON.stringify(descriptor.env, null, 2));
1538
+ console.log(JSON.stringify(env, null, 2));
1526
1539
  return;
1527
1540
  }
1528
- for (const [key, value] of Object.entries(descriptor.env)) {
1541
+ for (const [key, value] of Object.entries(env)) {
1529
1542
  console.log(`${key}=${shellQuoteEnv(value)}`);
1530
1543
  }
1531
1544
  }
@@ -2846,8 +2859,9 @@ List all CHR instances or show detailed info for one.
2846
2859
  case "inspect":
2847
2860
  console.log(`quickchr inspect <name> [--json]
2848
2861
 
2849
- Print a stable JSON descriptor for a running CHR instance: ports, URLs,
2850
- credentials, status, and subprocess env vars.
2862
+ Print a stable JSON descriptor for a running CHR instance: status, machine
2863
+ identity, and per-service connection facts (rest-api, native-api, ssh).
2864
+ Use 'quickchr env' for subprocess env vars.
2851
2865
 
2852
2866
  <name> Name of a running CHR instance.
2853
2867
  --json Accepted for parity; output is always JSON.`);
package/src/index.ts CHANGED
@@ -32,6 +32,8 @@ export type {
32
32
  Channel,
33
33
  ChrInstance,
34
34
  ChrPorts,
35
+ CustomForward,
36
+ Descriptor,
35
37
  DeviceModeOptions,
36
38
  DoctorCheck,
37
39
  DoctorResult,
@@ -44,11 +46,11 @@ export type {
44
46
  LicenseLevel,
45
47
  LicenseOptions,
46
48
  MachineConfig,
47
- MachineDescriptor,
48
49
  MachineState,
49
50
  NetworkConfig,
50
51
  NetworkMode,
51
52
  NetworkSpecifier,
53
+ NetworkTopologyEntry,
52
54
  PackageManager,
53
55
  PlatformInfo,
54
56
  PortMapping,
@@ -59,12 +61,14 @@ export type {
59
61
  QgaNetworkIpAddress,
60
62
  QgaOsInfo,
61
63
  QgaTimezone,
64
+ ServiceEndpoint,
62
65
  ServiceName,
63
66
  SnapshotInfo,
67
+ SshServiceEndpoint,
64
68
  StartOptions,
65
69
  } from "./lib/types.ts";
66
70
 
67
- export { QuickCHRError } from "./lib/types.ts";
71
+ export { QuickCHRError, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./lib/types.ts";
68
72
  export type { ErrorCode, HostInterface } from "./lib/types.ts";
69
73
 
70
74
  // Interface detection
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Post-boot provisioning — user creation, admin disable via REST API,
3
3
  * with a console transport fallback when REST is unavailable.
4
+ * cspell:ignore NUL
4
5
  */
5
6
 
7
+ import { createHash } from "node:crypto";
6
8
  import { mkdirSync } from "node:fs";
7
- import { join } from "node:path";
8
- import { QuickCHRError } from "./types.ts";
9
+ import { dirname, join } from "node:path";
10
+ import { QuickCHRError, type ManagedSshKey } from "./types.ts";
9
11
  import { saveInstanceCredentials } from "./credentials.ts";
10
12
  import { generatePassword } from "./password.ts";
11
13
  import { waitForBoot } from "./qemu.ts";
@@ -19,6 +21,9 @@ export const QUICKCHR_USER = "quickchr";
19
21
  export interface ProvisionResult {
20
22
  /** The user that was created (or null if none). */
21
23
  user: { name: string; password: string } | null;
24
+ /** The managed SSH key installed for that user, if provisioning installed one
25
+ * (issue #74). Absent when no key was installed. */
26
+ managedSshKey?: ManagedSshKey;
22
27
  }
23
28
 
24
29
  async function readUser(httpPort: number, auth: string, name: string): Promise<Record<string, unknown> | undefined> {
@@ -261,35 +266,230 @@ async function consoleProvision(
261
266
  }
262
267
 
263
268
  /**
264
- * Generate an ed25519 SSH keypair and install the public key on the CHR for `username`.
269
+ * Console markers that mean RouterOS rejected a `/user/ssh-keys/add`. RouterOS
270
+ * reports rejection inline (no status code) — these are the parser/command error
271
+ * prefixes it emits. Kept deliberately specific (not bare "expected"/"invalid",
272
+ * which appear in benign output) so it doesn't false-positive. Exported so the
273
+ * ssh-keys lab runner classifies rejections identically instead of duplicating it.
274
+ */
275
+ export const SSH_KEY_REJECTION_PATTERN = /failure:|syntax error|no such item|bad command name|expected end of/i;
276
+
277
+ /** Managed-key algorithm quickchr generates. Grounded as accepted across the
278
+ * provisioning floor (7.20.8) and current stable in REPORT.md — issue #74. */
279
+ const MANAGED_SSH_KEY_ALGORITHM = "ed25519";
280
+ export const SSH_NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
281
+
282
+ /** OpenSSH's `-F <config>` needs a file it can actually open — Win32-OpenSSH's
283
+ * config-file loader rejects the null device itself ("Can't open user config
284
+ * file NUL: No such file or directory", exit 255; grounded on windows-latest
285
+ * OpenSSH_10.3p1 in issue #87), unlike POSIX where /dev/null opens fine as an
286
+ * empty config. A real empty file on disk is the config-suppression trick
287
+ * that works identically on every platform, so `-F` always gets one of these
288
+ * instead of {@link SSH_NULL_DEVICE}. */
289
+ export async function ensureEmptySshConfig(dir: string): Promise<string> {
290
+ const path = join(dir, "empty_ssh_config");
291
+ await Bun.write(path, "");
292
+ return path;
293
+ }
294
+
295
+ export type SshKeyListRow = {
296
+ user?: string;
297
+ info?: string;
298
+ "key-owner"?: string;
299
+ "key-type"?: string;
300
+ fingerprint?: string;
301
+ };
302
+
303
+ function sshKeyOwner(row: SshKeyListRow): string {
304
+ return row.info ?? row["key-owner"] ?? "";
305
+ }
306
+
307
+ export function opensshSha256Fingerprint(publicKey: string): string | undefined {
308
+ const keyBlob = publicKey.trim().split(/\s+/)[1];
309
+ if (!keyBlob) return undefined;
310
+ try {
311
+ const digest = createHash("sha256").update(Buffer.from(keyBlob, "base64")).digest("base64").replace(/=+$/, "");
312
+ return `SHA256:${digest}`;
313
+ } catch {
314
+ return undefined;
315
+ }
316
+ }
317
+
318
+ function normalizeSshFingerprint(fingerprint: string): string {
319
+ return fingerprint.replace(/=+$/, "");
320
+ }
321
+
322
+ export function matchesManagedSshKey(row: SshKeyListRow, username: string, keyComment: string, fingerprint?: string): boolean {
323
+ if (row.user !== username) return false;
324
+ if (sshKeyOwner(row) !== keyComment) return false;
325
+ if (row["key-type"] && row["key-type"] !== MANAGED_SSH_KEY_ALGORITHM) return false;
326
+ if (fingerprint && row.fingerprint && normalizeSshFingerprint(row.fingerprint) !== normalizeSshFingerprint(fingerprint)) return false;
327
+ return true;
328
+ }
329
+
330
+ const SSH_KEY_LIST_BUDGET_MS = 30_000;
331
+
332
+ export interface SshKeyListingVerification {
333
+ listed: boolean;
334
+ attempts: number;
335
+ elapsedMs: number;
336
+ lastDiagnostic: string;
337
+ }
338
+
339
+ /** Wait for RouterOS to expose the generated key through `/user/ssh-keys`.
340
+ * A request receives the full remaining convergence budget because the first
341
+ * read of this endpoint can itself take many seconds under TCG. */
342
+ export async function waitForManagedSshKeyListing(
343
+ httpPort: number,
344
+ auth: string,
345
+ username: string,
346
+ keyComment: string,
347
+ fingerprint: string,
348
+ timeoutMs = SSH_KEY_LIST_BUDGET_MS,
349
+ ): Promise<SshKeyListingVerification> {
350
+ const startedAt = Date.now();
351
+ const deadline = startedAt + timeoutMs;
352
+ let attempts = 0;
353
+ let lastDiagnostic = "no REST attempt completed";
354
+
355
+ while (Date.now() < deadline) {
356
+ attempts++;
357
+ const remainingMs = Math.max(1, deadline - Date.now());
358
+ try {
359
+ const { status, body } = await restGet(
360
+ `http://127.0.0.1:${httpPort}/rest/user/ssh-keys`,
361
+ auth,
362
+ remainingMs,
363
+ );
364
+ if (status >= 200 && status < 300) {
365
+ let keys: SshKeyListRow[] | undefined;
366
+ try {
367
+ keys = JSON.parse(body) as SshKeyListRow[];
368
+ } catch (parseErr) {
369
+ lastDiagnostic = `HTTP ${status}: non-JSON body (${String(parseErr)}): ${body}`;
370
+ }
371
+ if (Array.isArray(keys)) {
372
+ if (keys.some((key) => matchesManagedSshKey(key, username, keyComment, fingerprint))) {
373
+ return { listed: true, attempts, elapsedMs: Date.now() - startedAt, lastDiagnostic: body };
374
+ }
375
+ lastDiagnostic = `HTTP ${status}: no matching key in ${keys.length} row(s)`;
376
+ } else if (keys !== undefined) {
377
+ lastDiagnostic = `HTTP ${status}: expected an array, received ${body}`;
378
+ }
379
+ } else {
380
+ lastDiagnostic = `HTTP ${status}: ${body}`;
381
+ }
382
+ } catch (e) {
383
+ lastDiagnostic = String(e);
384
+ }
385
+
386
+ const sleepMs = Math.min(500, Math.max(0, deadline - Date.now()));
387
+ if (sleepMs > 0) await Bun.sleep(sleepMs);
388
+ }
389
+
390
+ return { listed: false, attempts, elapsedMs: Date.now() - startedAt, lastDiagnostic };
391
+ }
392
+
393
+ /** Cap on the diagnostic text surfaced from a failed batch-login probe — enough
394
+ * to see the ssh client's actual error, not so much a noisy client fills logs. */
395
+ const BATCH_LOGIN_DIAGNOSTIC_LIMIT = 2000;
396
+
397
+ interface BatchLoginResult {
398
+ verified: boolean;
399
+ /** ssh's combined stdout+stderr (or the spawn error), truncated. Always
400
+ * populated so a failure is diagnosable instead of collapsing to a bool
401
+ * (issue #87 — this was the missing piece on the first Windows CI run). */
402
+ diagnostic: string;
403
+ }
404
+
405
+ /** Attempt a real host-OpenSSH batch login with the managed private key — the
406
+ * exact mode (`BatchMode=yes`, `PasswordAuthentication=no`, `IdentitiesOnly=yes`,
407
+ * ignoring ssh_config) that centrs and the #71 descriptor need. Verified only
408
+ * on a clean passwordless login. Best-effort: never throws, and is killed after
409
+ * 15s rather than hanging provisioning. */
410
+ async function verifyBatchLogin(sshPort: number, username: string, privateKeyPath: string): Promise<BatchLoginResult> {
411
+ // No SSH port forwarded (or not passed in) → can't prove batch auth from the host.
412
+ if (!sshPort || sshPort <= 0) return { verified: false, diagnostic: "no SSH port forwarded" };
413
+ try {
414
+ const emptyConfig = await ensureEmptySshConfig(dirname(privateKeyPath));
415
+ const proc = Bun.spawn(
416
+ [
417
+ "ssh",
418
+ "-F", emptyConfig,
419
+ "-o", "StrictHostKeyChecking=no",
420
+ "-o", `UserKnownHostsFile=${SSH_NULL_DEVICE}`,
421
+ "-o", "PasswordAuthentication=no",
422
+ "-o", "IdentitiesOnly=yes",
423
+ "-o", "BatchMode=yes",
424
+ "-o", "ConnectTimeout=10",
425
+ "-i", privateKeyPath,
426
+ `${username}@127.0.0.1`, "-p", String(sshPort),
427
+ ':put "quickchr-ssh-batch-ok"',
428
+ ],
429
+ { stdout: "pipe", stderr: "pipe", stdin: "ignore" },
430
+ );
431
+ const timer = setTimeout(() => { try { proc.kill(); } catch { /* already gone */ } }, 15_000);
432
+ try {
433
+ const [out, err] = await Promise.all([
434
+ new Response(proc.stdout).text(),
435
+ new Response(proc.stderr).text(),
436
+ ]);
437
+ const exitCode = await proc.exited;
438
+ const combined = (out + err).trim();
439
+ const verified = combined.includes("quickchr-ssh-batch-ok");
440
+ const diagnostic = verified
441
+ ? "ok"
442
+ : `exit ${exitCode}: ${(combined || "<no output>").slice(0, BATCH_LOGIN_DIAGNOSTIC_LIMIT)}`;
443
+ return { verified, diagnostic };
444
+ } finally {
445
+ clearTimeout(timer);
446
+ }
447
+ } catch (e) {
448
+ return { verified: false, diagnostic: `ssh spawn failed: ${String(e)}` };
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Generate an ed25519 SSH keypair and install the public key on the CHR for `username`,
454
+ * then verify it works for a host-OpenSSH batch login.
265
455
  * Keys are stored in `<machineDir>/ssh/id_ed25519` (private) and `.pub` (public).
266
456
  * This enables SSH transport without passwords for the quickchr managed user.
457
+ *
458
+ * Returns the {@link ManagedSshKey} fact (key path, algorithm, and whether a real
459
+ * `BatchMode=yes` login succeeded) so callers can persist it for the #71 descriptor.
267
460
  */
268
461
  export async function installSshKey(
269
462
  httpPort: number,
463
+ sshPort: number,
270
464
  username: string,
271
465
  machineName: string,
272
466
  machineDir: string,
273
467
  portBase?: number,
274
- ): Promise<void> {
468
+ logger?: ProgressLogger,
469
+ ): Promise<ManagedSshKey> {
275
470
  const sshDir = join(machineDir, "ssh");
276
471
  mkdirSync(sshDir, { recursive: true });
277
472
 
278
473
  const privateKeyPath = join(sshDir, "id_ed25519");
474
+ const keyComment = `quickchr@${machineName}`;
279
475
 
280
476
  const keygen = Bun.spawnSync(
281
- ["ssh-keygen", "-t", "ed25519", "-f", privateKeyPath, "-N", "", "-C", `quickchr@${machineName}`],
477
+ ["ssh-keygen", "-t", MANAGED_SSH_KEY_ALGORITHM, "-f", privateKeyPath, "-N", "", "-C", keyComment],
282
478
  { stdout: "pipe", stderr: "pipe" },
283
479
  );
284
480
  if (keygen.exitCode !== 0) {
285
- throw new Error(`ssh-keygen failed: ${new TextDecoder().decode(keygen.stderr)}`);
481
+ throw new QuickCHRError("SPAWN_FAILED", `ssh-keygen failed: ${new TextDecoder().decode(keygen.stderr)}`);
286
482
  }
287
483
 
288
484
  const pubKey = (await Bun.file(`${privateKeyPath}.pub`).text()).trim();
485
+ const expectedFingerprint = opensshSha256Fingerprint(pubKey);
486
+ if (!expectedFingerprint) {
487
+ throw new QuickCHRError("PROCESS_FAILED", "Could not compute fingerprint for quickchr-generated SSH public key");
488
+ }
289
489
 
290
490
  // Install via serial console — commits synchronously unlike the REST endpoint
291
491
  // which may return 200 OK before the key is durable in RouterOS storage.
292
- await consoleExec(
492
+ const { output: addOutput } = await consoleExec(
293
493
  machineDir,
294
494
  `/user/ssh-keys/add user="${username}" key="${pubKey}"`,
295
495
  "admin",
@@ -298,30 +498,50 @@ export async function installSshKey(
298
498
  portBase,
299
499
  );
300
500
 
301
- // Verify the key appears in the REST listing.
302
- const auth = `Basic ${btoa("admin:")}`;
303
- const deadline = Date.now() + 10_000;
304
- let lastListBody = "";
305
- while (Date.now() < deadline) {
306
- try {
307
- const { status, body: listBody } = await restGet(
308
- `http://127.0.0.1:${httpPort}/rest/user/ssh-keys`,
309
- auth,
310
- 5_000,
311
- );
312
- if (status >= 200 && status < 300) {
313
- lastListBody = listBody;
314
- const keys = JSON.parse(listBody) as Array<{ user?: string }>;
315
- if (Array.isArray(keys) && keys.some((k) => k.user === username)) return;
316
- } else {
317
- lastListBody = `HTTP ${status}`;
318
- }
319
- } catch (e) {
320
- lastListBody = String(e);
321
- }
322
- await Bun.sleep(500);
501
+ // RouterOS reports key rejection (e.g. an unsupported algorithm on the running
502
+ // version, or a malformed key) inline on the console rather than by any status
503
+ // code. Surface it immediately so the failure is diagnosable, instead of letting
504
+ // it masquerade as the generic "did not appear in REST listing" timeout below.
505
+ if (SSH_KEY_REJECTION_PATTERN.test(addOutput)) {
506
+ throw new QuickCHRError("EXEC_FAILED", `RouterOS rejected the SSH key for ${username}: ${addOutput.trim()}`);
323
507
  }
324
- throw new Error(`SSH key for ${username} installed via console but did not appear in REST listing within 10s (last: ${lastListBody})`);
508
+
509
+ // Verify the generated key appears in the REST listing. Match the public-key
510
+ // comment (`key-owner` on older 7.x, `info` on newer 7.x) rather than just the
511
+ // user, so an older key for the same user cannot satisfy this check.
512
+ const listResult = await waitForManagedSshKeyListing(
513
+ httpPort,
514
+ `Basic ${btoa("admin:")}`,
515
+ username,
516
+ keyComment,
517
+ expectedFingerprint,
518
+ );
519
+ if (!listResult.listed) {
520
+ throw new QuickCHRError(
521
+ "PROCESS_FAILED",
522
+ `SSH key for ${username} installed via console but did not appear in REST listing within 30s ` +
523
+ `after ${listResult.attempts} attempt(s) and ${listResult.elapsedMs}ms ` +
524
+ `(console output: ${addOutput.trim() || "<empty>"}; last REST attempt: ${listResult.lastDiagnostic})`,
525
+ );
526
+ }
527
+ if (listResult.attempts > 1 || listResult.elapsedMs > 5_000) {
528
+ logger?.status(` SSH key listing verified after ${listResult.attempts} attempt(s) in ${listResult.elapsedMs}ms`);
529
+ }
530
+
531
+ // Presence in the listing is necessary but not sufficient — prove the key
532
+ // actually authenticates a host-OpenSSH batch client. Non-fatal: an unverified
533
+ // key is still installed, we just record batchVerified=false so the #71
534
+ // descriptor won't advertise batch key auth it can't stand behind.
535
+ const { verified: batchVerified, diagnostic: batchDiagnostic } = await verifyBatchLogin(sshPort, username, privateKeyPath);
536
+ if (!batchVerified) {
537
+ logger?.status(` SSH batch login not verified: ${batchDiagnostic}`);
538
+ }
539
+ return {
540
+ privateKeyPath,
541
+ algorithm: MANAGED_SSH_KEY_ALGORITHM,
542
+ batchVerified,
543
+ verifiedAt: batchVerified ? new Date().toISOString() : undefined,
544
+ };
325
545
  }
326
546
 
327
547
  /** Run all provisioning steps based on the config.
@@ -345,6 +565,7 @@ export async function provision(
345
565
  logger?: ProgressLogger,
346
566
  machineDir?: string,
347
567
  portBase?: number,
568
+ sshPort?: number,
348
569
  ): Promise<ProvisionResult> {
349
570
  const log = logger ?? createLogger();
350
571
 
@@ -375,7 +596,7 @@ export async function provision(
375
596
  if (secureLogin && !user && effectiveUser) {
376
597
  try {
377
598
  await waitForRest(httpPort, 30_000);
378
- await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
599
+ result.managedSshKey = await installSshKey(httpPort, sshPort ?? 0, effectiveUser.name, machineName, machineDir, portBase, log);
379
600
  } catch (keyErr) {
380
601
  log.warn(`SSH key install failed after console provisioning (SSH transport will fall back to password): ${keyErr}`);
381
602
  }
@@ -393,12 +614,13 @@ export async function provision(
393
614
  saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
394
615
  }
395
616
 
617
+ let managedSshKey: ManagedSshKey | undefined;
396
618
  if (secureLogin && !user && effectiveUser && machineDir) {
397
619
  // Brief pause so RouterOS propagates the new user to all subsystems
398
620
  // (including the SSH key store) before we attempt key installation.
399
621
  await Bun.sleep(1000);
400
622
  try {
401
- await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
623
+ managedSshKey = await installSshKey(httpPort, sshPort ?? 0, effectiveUser.name, machineName, machineDir, portBase, log);
402
624
  } catch (e) {
403
625
  log.warn(`SSH key install failed (SSH transport will fall back to password): ${e}`);
404
626
  }
@@ -416,5 +638,5 @@ export async function provision(
416
638
  await disableAdmin(httpPort, verifyAuth);
417
639
  }
418
640
 
419
- return { user: effectiveUser };
641
+ return { user: effectiveUser, managedSshKey };
420
642
  }
@@ -7,19 +7,25 @@ import type {
7
7
  Channel,
8
8
  ChrInstance,
9
9
  ChrLoadSample,
10
+ CustomForward,
11
+ Descriptor,
10
12
  DeviceModeOptions,
11
13
  DoctorResult,
12
14
  ExecOptions,
13
15
  ExecResult,
14
16
  LicenseInput,
15
17
  LicenseLevel,
16
- MachineDescriptor,
17
18
  MachineState,
19
+ NetworkTopologyEntry,
20
+ PortMapping,
18
21
  QgaCommand,
22
+ ServiceEndpoint,
19
23
  SnapshotInfo,
24
+ SshServiceEndpoint,
20
25
  StartOptions,
21
26
  } from "./types.ts";
22
- import { QuickCHRError, ARCHES, CHANNELS } from "./types.ts";
27
+ import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./types.ts";
28
+ import packageJson from "../../package.json";
23
29
  import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
24
30
  import {
25
31
  resolveVersion,
@@ -56,7 +62,7 @@ import { provision } from "./provision.ts";
56
62
  import { renewLicense, getLicenseInfo } from "./license.ts";
57
63
  import { resolveAuth, resolveCreds } from "./auth.ts";
58
64
  import { scpPush, scpPull } from "./scp.ts";
59
- import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
65
+ import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
60
66
  import { restExecute } from "./exec.ts";
61
67
  import { qgaExec } from "./qga.ts";
62
68
  import { consoleExec } from "./console.ts";
@@ -86,7 +92,7 @@ function defaultMem(arch: Arch, override?: number): number {
86
92
  }
87
93
 
88
94
  /** Default boot timeout — accel mode and cross-arch emulation affect how slow the VM boots. */
89
- function defaultBootTimeout(arch: Arch, withPackages?: boolean, accel?: string): number {
95
+ export function defaultBootTimeout(arch: Arch, withPackages?: boolean, accel?: string): number {
90
96
  const cross = isCrossArchEmulation(arch);
91
97
  const factor = accelTimeoutFactor(accel ?? "tcg", cross);
92
98
  // Base: 120s for native, scaled by factor (TCG cross-arch = 15× = 1800s max)
@@ -242,46 +248,157 @@ function createInstance(state: MachineState): ChrInstance {
242
248
  BASICAUTH: rawCreds,
243
249
  };
244
250
  };
245
- const buildDescriptor = (): MachineDescriptor => {
251
+ // Service keys beyond these five are surfaced as `customForwards` (winbox, plus
252
+ // any user-added extraPorts) — see docs/centrs-interface.md "CustomForward".
253
+ const CANONICAL_PORT_NAMES = new Set(["http", "https", "api", "api-ssl", "ssh"]);
254
+
255
+ const buildDescriptor = (): Descriptor => {
246
256
  if (state.status !== "running") {
247
257
  throw new QuickCHRError(
248
258
  "MACHINE_STOPPED",
249
259
  `Machine "${state.name}" must be running to inspect its connection environment (current status: ${state.status}).`,
250
260
  );
251
261
  }
262
+
252
263
  const auth = resolveAuth(state);
253
- const env = buildEnv();
254
- const basic = env.QUICKCHR_AUTH ?? `${auth.user}:`;
264
+ const creds = resolveCreds(state);
265
+ const basic = `${creds.user}:${creds.password}`;
266
+ const storedCreds = getInstanceCredentials(state.name);
267
+ // resolveAuth/resolveCreds never throw — when disableAdmin is true and no
268
+ // provisioned/stored user exists, they still fall back to the meaningless
269
+ // admin:"" tuple (auth.ts docstring: "the caller will get a 401"). Don't
270
+ // report a service available:true off that fallback alone.
271
+ const disableAdminLockout = state.disableAdmin === true && !state.user && !storedCreds;
272
+ // A provisioned user whose password is the STORED_IN_SECRETS_PASSWORD sentinel
273
+ // only resolves a real password when the per-instance credential store actually
274
+ // has an entry — otherwise resolveAuth/resolveCreds fall through to returning the
275
+ // literal sentinel string as the "password". Don't report available:true (or leak
276
+ // the sentinel as a usable password) off that unresolvable case either, regardless
277
+ // of disableAdmin.
278
+ const sentinelUnresolvable = state.user?.password === STORED_IN_SECRETS_PASSWORD && !storedCreds;
279
+ const credentialsAvailable = !disableAdminLockout && !sentinelUnresolvable;
280
+
281
+ const buildHttpService = (
282
+ securePM: PortMapping | undefined,
283
+ plainPM: PortMapping | undefined,
284
+ serviceLabel: string,
285
+ scheme: { secure: string; plain: string },
286
+ authObj: { username: string; password?: string; basic?: string; header?: string },
287
+ urlSuffix = "",
288
+ ): ServiceEndpoint => {
289
+ // Plain-first (#95): on a stock CHR the TLS services are not dialable —
290
+ // `www-ssl` is disabled and `api-ssl` is certificate-less (TLS alert 40) —
291
+ // so preferring the secure forward advertised dead endpoints. Prefer the
292
+ // plain forward for each service; the secure forward remains the fallback
293
+ // so excluded plain ports still resolve when a secure forward is available
294
+ // (with `tls: true`). Secure preference can return once boot provisioning
295
+ // installs a certificate and enables www-ssl.
296
+ const chosen = plainPM ?? securePM;
297
+ if (!chosen) {
298
+ return { available: false, unavailableReason: `no forwarded port for ${serviceLabel}` };
299
+ }
300
+ const tls = chosen === securePM;
301
+ const echo = {
302
+ host: "127.0.0.1",
303
+ port: chosen.host,
304
+ guestPort: chosen.guest,
305
+ transport: chosen.proto,
306
+ tls,
307
+ url: `${tls ? scheme.secure : scheme.plain}://127.0.0.1:${chosen.host}${urlSuffix}`,
308
+ source: { provider: "quickchr" as const, portMappingName: chosen.name },
309
+ };
310
+ if (!credentialsAvailable) {
311
+ return { available: false, unavailableReason: "admin disabled, no user provisioned", ...echo };
312
+ }
313
+ return { available: true, ...echo, auth: authObj };
314
+ };
315
+
316
+ // Preference order lives in buildHttpService (plain-first, #95); this also
317
+ // keeps the excludePorts fix: excluding "http" while keeping "https" now
318
+ // resolves rest-api onto the surviving secure port instead of a port that
319
+ // doesn't exist.
320
+ const restApi = buildHttpService(
321
+ state.ports.https,
322
+ state.ports.http,
323
+ "rest-api",
324
+ { secure: "https", plain: "http" },
325
+ { username: creds.user, password: creds.password, basic, header: auth.header },
326
+ "/rest",
327
+ );
328
+ const nativeApi = buildHttpService(
329
+ state.ports["api-ssl"],
330
+ state.ports.api,
331
+ "native-api",
332
+ { secure: "tls", plain: "tcp" },
333
+ { username: creds.user, password: creds.password },
334
+ );
335
+
336
+ const sshPM = state.ports.ssh;
337
+ const sshUsername = state.user?.name ?? "admin";
338
+ const managedKey = state.managedSshKey;
339
+ const batchVerified = managedKey?.batchVerified === true;
340
+ const sshModes: Array<"private-key" | "agent-or-config" | "password"> = [];
341
+ if (managedKey) sshModes.push("private-key");
342
+ sshModes.push("agent-or-config");
343
+ if (credentialsAvailable) sshModes.push("password");
344
+
345
+ const sshService: SshServiceEndpoint = !sshPM
346
+ ? { available: false, unavailableReason: "no forwarded port for ssh" }
347
+ : {
348
+ available: true,
349
+ host: "127.0.0.1",
350
+ port: sshPM.host,
351
+ guestPort: sshPM.guest,
352
+ transport: sshPM.proto,
353
+ tls: false,
354
+ url: `ssh://${sshUsername}@127.0.0.1:${sshPM.host}`,
355
+ source: { provider: "quickchr" as const, portMappingName: "ssh" },
356
+ auth: {
357
+ username: sshUsername,
358
+ ...(batchVerified && managedKey ? { privateKeyPath: managedKey.privateKeyPath } : {}),
359
+ modes: sshModes,
360
+ // Only "private-key" is ever vouched for here, and only once verified —
361
+ // "agent-or-config" is host ssh-agent/~/.ssh/config policy quickchr has
362
+ // no way to check, so it stays out of batchModes (Done-when's own
363
+ // unverified/absent-key example expects batchModes: [], not
364
+ // ["agent-or-config"]).
365
+ batchModes: batchVerified ? ["private-key"] : [],
366
+ passwordAvailable: credentialsAvailable,
367
+ },
368
+ };
369
+
370
+ const customForwards: CustomForward[] = Object.entries(state.ports)
371
+ .filter(([name]) => !CANONICAL_PORT_NAMES.has(name))
372
+ .map(([name, pm]) => ({
373
+ name,
374
+ transport: pm.proto,
375
+ host: "127.0.0.1",
376
+ hostPort: pm.host,
377
+ guestPort: pm.guest,
378
+ }));
379
+
380
+ const networks: NetworkTopologyEntry[] = state.networks.map((n) => ({ id: n.id, specifier: n.specifier }));
381
+
255
382
  return {
256
- name: state.name,
383
+ descriptorVersion: QUICKCHR_DESCRIPTOR_VERSION,
384
+ quickchr: { packageVersion: packageJson.version },
257
385
  status: "running",
386
+ name: state.name,
258
387
  version: state.version,
259
388
  arch: state.arch,
260
389
  cpu: state.cpu,
261
390
  mem: state.mem,
262
391
  pid: state.pid ?? null,
263
- ports,
264
- portMappings: state.ports,
265
- urls: {
266
- http: restUrl,
267
- rest: `${restUrl}/rest`,
268
- restBase: `${restUrl}/rest`,
269
- https: ports.https ? `https://127.0.0.1:${ports.https}` : undefined,
270
- ssh: ports.ssh ? `ssh://${auth.user}@127.0.0.1:${ports.ssh}` : undefined,
271
- api: ports.api ? `tcp://127.0.0.1:${ports.api}` : undefined,
272
- apiSsl: ports.apiSsl ? `tls://127.0.0.1:${ports.apiSsl}` : undefined,
273
- winbox: ports.winbox ? `tcp://127.0.0.1:${ports.winbox}` : undefined,
274
- },
275
- auth: {
276
- user: auth.user,
277
- password: basic.slice(auth.user.length + 1),
278
- basic,
279
- header: auth.header,
280
- },
281
- env,
282
392
  machineDir: state.machineDir,
283
393
  createdAt: state.createdAt,
284
394
  lastStartedAt: state.lastStartedAt ?? null,
395
+ services: {
396
+ [SERVICE_IDS.restApi]: restApi,
397
+ [SERVICE_IDS.nativeApi]: nativeApi,
398
+ [SERVICE_IDS.ssh]: sshService,
399
+ },
400
+ ...(customForwards.length > 0 ? { customForwards } : {}),
401
+ networks,
285
402
  };
286
403
  };
287
404
 
@@ -636,7 +753,7 @@ function createInstance(state: MachineState): ChrInstance {
636
753
  return buildEnv();
637
754
  },
638
755
 
639
- async descriptor(): Promise<MachineDescriptor> {
756
+ async descriptor(): Promise<Descriptor> {
640
757
  return buildDescriptor();
641
758
  },
642
759
 
@@ -1643,14 +1760,25 @@ export class QuickCHR {
1643
1760
  }
1644
1761
 
1645
1762
  if (opts.user || opts.disableAdmin || opts.secureLogin === true) {
1646
- const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase);
1647
- if (result.user) {
1648
- // Persist user info in state (password placeholder — real password in secret store)
1763
+ const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase, chrPorts.ssh);
1764
+ if (result.user || result.managedSshKey) {
1765
+ // Persist user info + managed SSH key fact in state (password placeholder —
1766
+ // real password in secret store). The managedSshKey fact is what the #71
1767
+ // descriptor reads to advertise SSH private-key batch auth (only when verified).
1649
1768
  const current = loadMachine(machineState.name);
1650
1769
  if (current) {
1651
- current.user = { name: result.user.name, password: STORED_IN_SECRETS_PASSWORD };
1770
+ if (result.user) current.user = { name: result.user.name, password: STORED_IN_SECRETS_PASSWORD };
1771
+ if (result.managedSshKey) current.managedSshKey = result.managedSshKey;
1652
1772
  saveMachine(current);
1653
1773
  }
1774
+ }
1775
+ if (result.managedSshKey) {
1776
+ machineState.managedSshKey = result.managedSshKey;
1777
+ if (!result.managedSshKey.batchVerified) {
1778
+ log.warn(" SSH key installed but batch login could not be verified — SSH transport may fall back to password");
1779
+ }
1780
+ }
1781
+ if (result.user) {
1654
1782
  machineState.user = { name: result.user.name, password: result.user.password };
1655
1783
  if (!opts.user) {
1656
1784
  // Auto-created quickchr account — credential display is handled by the caller (wizard/CLI)
package/src/lib/types.ts CHANGED
@@ -215,6 +215,26 @@ export interface MachineConfig {
215
215
  bootDiskFormat?: BootDiskFormat;
216
216
  }
217
217
 
218
+ /**
219
+ * Facts about the SSH keypair quickchr generated and installed for its managed
220
+ * user during provisioning. Persisted on {@link MachineState} so consumers (the
221
+ * #71 descriptor contract) can advertise SSH private-key batch auth as usable —
222
+ * but only when `batchVerified` is true. See `test/lab/ssh-keys/REPORT.md`.
223
+ */
224
+ export interface ManagedSshKey {
225
+ /** Absolute path to the private key on the host (public key is `${path}.pub`). */
226
+ privateKeyPath: string;
227
+ /** Key algorithm. Currently always `ed25519` — grounded across quickchr's
228
+ * provisioning floor and current stable in REPORT.md (issue #74). */
229
+ algorithm: string;
230
+ /** True only when a host-OpenSSH `BatchMode=yes` / `PasswordAuthentication=no`
231
+ * login with this key actually succeeded — not merely that the key appears in
232
+ * RouterOS's `/user/ssh-keys` listing. This is the signal #71 gates on. */
233
+ batchVerified: boolean;
234
+ /** ISO timestamp of the successful batch verification, when one happened. */
235
+ verifiedAt?: string;
236
+ }
237
+
218
238
  export interface MachineState extends MachineConfig {
219
239
  createdAt: string;
220
240
  lastStartedAt?: string;
@@ -226,6 +246,8 @@ export interface MachineState extends MachineConfig {
226
246
  lastAccel?: string;
227
247
  /** Wall-clock ms from QEMU spawn to REST-ready for the most recent boot. */
228
248
  lastBootMs?: number;
249
+ /** Managed SSH key installed during provisioning, if any (issue #74). */
250
+ managedSshKey?: ManagedSshKey;
229
251
  }
230
252
 
231
253
  // --- Start Options ---
@@ -361,36 +383,138 @@ export interface ChrPorts {
361
383
  [key: string]: number;
362
384
  }
363
385
 
364
- export interface MachineDescriptor {
386
+ // --- Descriptor v1 (centrs interface contract) ---
387
+ //
388
+ // Live, credential-bearing connection-handoff shape for a *running* CHR instance —
389
+ // the quickchr <-> centrs interface contract (issue #71). Full contract, per-service
390
+ // mapping rules, and scope boundaries: docs/centrs-interface.md.
391
+
392
+ /** Canonical service IDs for {@link Descriptor.services}, matching centrs' `--via`
393
+ * values verbatim. Exported as constants for TS ergonomics
394
+ * (`SERVICE_IDS.nativeApi === "native-api"`) while the serialized JSON key stays the
395
+ * canonical string. See docs/centrs-interface.md "Service naming decision". */
396
+ export const SERVICE_IDS = {
397
+ restApi: "rest-api",
398
+ nativeApi: "native-api",
399
+ ssh: "ssh",
400
+ } as const;
401
+
402
+ /** Schema compatibility boundary for {@link Descriptor}. Changes within a version are
403
+ * additive-only (new optional fields/keys); bump only for a breaking restructure.
404
+ * See docs/centrs-interface.md "Forward-compat policy". */
405
+ export const QUICKCHR_DESCRIPTOR_VERSION = 1 as const;
406
+
407
+ /** Per-service endpoint shape, deliberately shared with centrs' internal
408
+ * `ServiceEndpoint` (tikoci/centrs#174) for `rest-api`/`native-api`. A consumer must
409
+ * gate on `available` before dialing — the `available: false` variant's extra fields
410
+ * are best-effort echoes, not a promise the endpoint is reachable.
411
+ * See docs/centrs-interface.md "ServiceEndpoint (rest-api, native-api)". */
412
+ export type ServiceEndpoint =
413
+ | {
414
+ available: true;
415
+ /** Hostname/IP — always `"127.0.0.1"` for quickchr's SLiRP-forwarded loopback
416
+ * ports. NOT a port; see the `PortMapping.host` naming-collision note in
417
+ * docs/centrs-interface.md. */
418
+ host: string;
419
+ port: number;
420
+ guestPort?: number;
421
+ transport: "tcp" | "udp";
422
+ /** true for https/apiSsl-backed endpoints, false otherwise. */
423
+ tls: boolean;
424
+ url?: string;
425
+ source?: { provider: "quickchr"; portMappingName?: string };
426
+ auth?: { username: string; password?: string; basic?: string; header?: string };
427
+ }
428
+ | {
429
+ available: false;
430
+ unavailableReason: string;
431
+ host?: string;
432
+ port?: number;
433
+ guestPort?: number;
434
+ transport?: "tcp" | "udp";
435
+ tls?: boolean;
436
+ url?: string;
437
+ source?: { provider: "quickchr"; portMappingName?: string };
438
+ };
439
+
440
+ /** SSH's per-endpoint shape. Extends the generic {@link ServiceEndpoint}'s
441
+ * available/host/port/transport/source discriminated union with an SSH-specific
442
+ * `auth` sub-shape substituted for the REST/native-api auth object — NOT an
443
+ * independent type. See docs/centrs-interface.md "SshServiceEndpoint". */
444
+ export type SshServiceEndpoint =
445
+ | ({ available: true } & Omit<Extract<ServiceEndpoint, { available: true }>, "auth"> & {
446
+ auth: {
447
+ /** `state.user?.name ?? "admin"`. */
448
+ username: string;
449
+ /** `managedSshKey.privateKeyPath` — ONLY set when `batchVerified === true`.
450
+ * Never emit an unverified path. */
451
+ privateKeyPath?: string;
452
+ /** Modes centrs *may* try (broader, not all vouched-for). */
453
+ modes: Array<"private-key" | "agent-or-config" | "password">;
454
+ /** The gate centrs enforces for `--via ssh` / `transfer --via sftp` — only
455
+ * modes quickchr actually vouches for. */
456
+ batchModes: Array<"private-key" | "agent-or-config">;
457
+ passwordAvailable?: boolean;
458
+ };
459
+ })
460
+ | Extract<ServiceEndpoint, { available: false }>;
461
+
462
+ /** Generic extra port-forward listing beyond the three canonical `services` keys
463
+ * (e.g. `winbox`, or a user's `extraPorts`). Descriptor-specific shape — do not leak
464
+ * internal `PortMapping` field names (there, `host` is a port number; here it's a
465
+ * hostname). See docs/centrs-interface.md "CustomForward". */
466
+ export interface CustomForward {
365
467
  name: string;
468
+ transport: "tcp" | "udp";
469
+ host: string;
470
+ hostPort: number;
471
+ guestPort: number;
472
+ }
473
+
474
+ /** Topology-only awareness of a machine's network interfaces — declarative, not a
475
+ * resolved connection fact. Do not add `available`/`host`/`port` here; that would
476
+ * imply a resolved-connection promise quickchr can't keep for DHCP-assigned segments.
477
+ * See docs/centrs-interface.md "Multi-network awareness". */
478
+ export interface NetworkTopologyEntry {
479
+ /** Matches `NetworkConfig.id` / boot order (`"net0"` = ether1). */
480
+ id: string;
481
+ /** Verbatim declared intent from `state.networks[].specifier`. */
482
+ specifier: NetworkSpecifier;
483
+ }
484
+
485
+ /** Live, credential-bearing connection-handoff descriptor for a *running* CHR
486
+ * instance — the quickchr <-> centrs interface contract (issue #71).
487
+ * `descriptor()` only ever returns this running shape; stopped machines throw
488
+ * `MACHINE_STOPPED` as today. A missing machine name never reaches this type —
489
+ * `QuickCHR.get(name)` returns `undefined` for that case (a typed absent value;
490
+ * no separate `MACHINE_NOT_FOUND` throw from this surface).
491
+ * See docs/centrs-interface.md "Descriptor v1 shape". */
492
+ export interface Descriptor {
493
+ descriptorVersion: 1;
494
+ quickchr: { packageVersion: string };
366
495
  status: "running";
496
+
497
+ name: string;
367
498
  version: string;
368
499
  arch: Arch;
369
500
  cpu: number;
370
501
  mem: number;
371
502
  pid: number | null;
372
- ports: ChrPorts;
373
- portMappings: Record<string, PortMapping>;
374
- urls: {
375
- http: string;
376
- rest: string;
377
- restBase: string;
378
- https?: string;
379
- ssh?: string;
380
- api?: string;
381
- apiSsl?: string;
382
- winbox?: string;
383
- };
384
- auth: {
385
- user: string;
386
- password: string;
387
- basic: string;
388
- header: string;
389
- };
390
- env: Record<string, string>;
391
503
  machineDir: string;
392
504
  createdAt: string;
393
505
  lastStartedAt: string | null;
506
+
507
+ services: {
508
+ "rest-api": ServiceEndpoint;
509
+ "native-api": ServiceEndpoint;
510
+ ssh: SshServiceEndpoint;
511
+ };
512
+
513
+ /** Only present when quickchr has forwards beyond the three `services` keys
514
+ * above (e.g. `winbox`, or a user's `extraPorts`). */
515
+ customForwards?: CustomForward[];
516
+ /** Topology-only; SLiRP-forwarded connection facts stay in `services`. */
517
+ networks?: NetworkTopologyEntry[];
394
518
  }
395
519
 
396
520
  /** Return value of ChrInstance.queryLoad(). */
@@ -549,8 +673,9 @@ export interface ChrInstance {
549
673
  * Bun.spawn(["bun", "my-script.ts"], { env: { ...process.env, ...await chr.subprocessEnv() } })
550
674
  */
551
675
  subprocessEnv(): Promise<Record<string, string>>;
552
- /** Build a stable machine-readable connection descriptor for a running instance. */
553
- descriptor(): Promise<MachineDescriptor>;
676
+ /** Build a stable machine-readable connection descriptor for a running instance —
677
+ * the quickchr <-> centrs interface contract (issue #71, docs/centrs-interface.md). */
678
+ descriptor(): Promise<Descriptor>;
554
679
 
555
680
  /** Sample QEMU guest load from the monitor. Returns null when the monitor is
556
681
  * unavailable (machine stopped or running in foreground mode). */