@tikoci/quickchr 0.4.7 → 0.5.0

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.
@@ -20,7 +20,13 @@ import {
20
20
  SERVICE_PORTS,
21
21
  QuickCHRError,
22
22
  } from "./types.ts";
23
- import { getNamedSocket } from "./socket-registry.ts";
23
+ import {
24
+ getNamedSocket,
25
+ getSocketSlot,
26
+ socketEndpointPath,
27
+ type SocketEntry,
28
+ } from "./socket-registry.ts";
29
+ import { assertValidResourceName } from "./names.ts";
24
30
  import { resolveInterfaceAlias, isSocketVmnetDaemonRunning } from "./platform.ts";
25
31
 
26
32
  /** Allocate a port block for a new instance, avoiding conflicts with existing machines. */
@@ -32,15 +38,15 @@ export function allocatePortBlock(
32
38
  return requestedBase;
33
39
  }
34
40
 
35
- let base = DEFAULT_PORT_BASE;
36
- const sorted = [...usedBases].sort((a, b) => a - b);
41
+ return nextUnusedBase(DEFAULT_PORT_BASE, usedBases);
42
+ }
37
43
 
38
- for (const used of sorted) {
39
- if (base >= used && base < used + PORTS_PER_BLOCK) {
40
- base = used + PORTS_PER_BLOCK;
41
- }
44
+ /** The lowest base at or above `from` whose block overlaps no used block. */
45
+ function nextUnusedBase(from: number, usedBases: number[]): number {
46
+ let base = from;
47
+ for (const used of [...usedBases].sort((a, b) => a - b)) {
48
+ if (Math.abs(base - used) < PORTS_PER_BLOCK) base = used + PORTS_PER_BLOCK;
42
49
  }
43
-
44
50
  return base;
45
51
  }
46
52
 
@@ -213,7 +219,10 @@ export async function findAvailablePortBlock(
213
219
  if (conflicts.length === 0) {
214
220
  return base;
215
221
  }
216
- base += PORTS_PER_BLOCK;
222
+ // Step past the probed conflict without stepping onto a used block: a block
223
+ // that is free on the wire can still belong to a stopped machine or to a
224
+ // creation that has not bound its ports yet (#140).
225
+ base = nextUnusedBase(base + PORTS_PER_BLOCK, usedBases);
217
226
  }
218
227
 
219
228
  throw new QuickCHRError(
@@ -329,6 +338,9 @@ function parseSocketSpecifier(input: string): NetworkSpecifier {
329
338
  "socket:: requires a link name (e.g. socket::mylink)",
330
339
  );
331
340
  }
341
+ // start() auto-creates a registry entry (and a networks/<name>.json file) for an
342
+ // unknown name, so the specifier is the other door into createNamedSocket().
343
+ assertValidResourceName(name, "named socket");
332
344
  return { type: "socket", name };
333
345
  }
334
346
 
@@ -388,6 +400,77 @@ export function resolveStartNetworks(
388
400
  return [{ specifier: "user", id: "net0" }];
389
401
  }
390
402
 
403
+ /** Derive a stable MAC for one NIC of one machine.
404
+ *
405
+ * QEMU's default is `52:54:00:12:34:56` incremented per NIC **within** a guest, so
406
+ * NIC index *N* holds the same address in *every* guest. Harmless while machines
407
+ * only have `user` NICs (separate SLIRP namespaces), fatal the moment two share an
408
+ * L2 segment — a named socket, a listen/connect pair, a TAP, or a RouterOS bridge
409
+ * on a hub VM. Forwarding then breaks in ways that look like a RouterOS or overlay
410
+ * defect rather than a launcher one (#154).
411
+ *
412
+ * The first octet is `0x02`: locally administered (bit 1 set) and unicast (bit 0
413
+ * clear), so these never collide with a vendor-assigned address. The remaining five
414
+ * octets come from a SHA-256 of the machine name, NIC index and `salt`, which makes
415
+ * the address reproducible for a given name instead of random per boot — the same
416
+ * lab rebuilt under the same names presents the same addresses.
417
+ */
418
+ export function deriveMac(name: string, index: number, salt = 0): string {
419
+ const digest = new Bun.CryptoHasher("sha256")
420
+ .update(`${name}\u0000${index}\u0000${salt}`)
421
+ .digest();
422
+ // Five octets after the fixed `02`, so a NIC's address is 48 bits total.
423
+ const octets = Array.from(digest.subarray(0, 5), (o) =>
424
+ o.toString(16).padStart(2, "0"),
425
+ );
426
+ return ["02", ...octets].join(":");
427
+ }
428
+
429
+ /** Assign a MAC to every NIC that lacks one, avoiding `taken`.
430
+ *
431
+ * Mutates and returns `networks`. Idempotent: a NIC that already has a MAC keeps it,
432
+ * so a machine's addresses are fixed at creation and survive restart, a re-resolve on
433
+ * a different platform, and any later change to {@link deriveMac} itself.
434
+ *
435
+ * **Call this only where a machine is created.** Changing a MAC on a machine that has
436
+ * already booted breaks it: RouterOS ties its persisted interface identity to the
437
+ * address, so a new one orphans the `ether1` holding the DHCP client and the guest
438
+ * comes up with no IP. Verified by A/B on a live CHR — see DESIGN.md, "NIC MAC
439
+ * Addresses". Machines created before #154 keep QEMU's defaults and must be recreated.
440
+ *
441
+ * A SHA-256 collision across five octets is vanishingly unlikely, but `taken` makes
442
+ * the guarantee structural rather than probabilistic — a duplicate here is the exact
443
+ * silent-L2 failure this exists to prevent, so it is worth not leaving to odds.
444
+ *
445
+ * `taken` is a snapshot, so it is only complete if no other creation is choosing at
446
+ * the same moment. Callers that create machines go through `claimNewMachine()` in
447
+ * quickchr.ts, which holds the allocation lock across this call and the port choice
448
+ * and adds in-flight creations' MACs to `taken` (#140). A new creation path must do
449
+ * the same rather than call this with a bare `getUsedMacs()`.
450
+ */
451
+ export function assignMacs(
452
+ name: string,
453
+ networks: NetworkConfig[],
454
+ taken: ReadonlySet<string> = new Set(),
455
+ ): NetworkConfig[] {
456
+ const used = new Set(taken);
457
+ for (const n of networks) {
458
+ if (n.mac) used.add(n.mac);
459
+ }
460
+ networks.forEach((n, i) => {
461
+ if (n.mac) return;
462
+ let salt = 0;
463
+ let mac = deriveMac(name, i, salt);
464
+ while (used.has(mac)) {
465
+ salt++;
466
+ mac = deriveMac(name, i, salt);
467
+ }
468
+ used.add(mac);
469
+ n.mac = mac;
470
+ });
471
+ return networks;
472
+ }
473
+
391
474
  /** Returns true when at least one network uses QEMU user-mode (hostfwd) networking.
392
475
  * Only user-mode networks forward localhost ports — shared/bridged/tap/socket networks
393
476
  * assign a DHCP address that is not reachable from host localhost. */
@@ -400,10 +483,26 @@ export function hasUserModeNetwork(networks: NetworkConfig[]): boolean {
400
483
  export interface ResolutionContext {
401
484
  platform: PlatformInfo;
402
485
  socketVmnet?: SocketVmnetInfo;
486
+ /** Machine being resolved. A named socket's endpoint role is per-machine, so the
487
+ * resolver needs to know who is asking; absent for callers that only want the
488
+ * shape of a specifier (dry runs, unit tests over the non-socket modes). */
489
+ machine?: string;
490
+ /** QEMU version string, when the caller knows it. `-netdev dgram` landed in 7.2;
491
+ * an unknown version does not block, because refusing to run on a QEMU we merely
492
+ * failed to parse is worse than the error the spawn would give. */
493
+ qemuVersion?: string;
403
494
  }
404
495
 
405
- function deviceArgs(id: string): string[] {
406
- return ["-device", `virtio-net-pci,netdev=${id}`];
496
+ /** `-device` for one NIC — the **only** place a NIC device is emitted.
497
+ *
498
+ * Takes the whole {@link NetworkConfig} rather than an id so a caller physically
499
+ * cannot emit a NIC without its MAC. `qemu.ts`'s fallback path builds its own
500
+ * `-netdev` strings but shares this, so both paths carry the address; every guest
501
+ * would otherwise get QEMU's default `52:54:00:12:34:56` sequence and two machines
502
+ * bridged onto one L2 segment would collide (#154). */
503
+ export function deviceArgs(config: NetworkConfig): string[] {
504
+ const mac = config.mac ? `,mac=${config.mac}` : "";
505
+ return ["-device", `virtio-net-pci,netdev=${config.id}${mac}`];
407
506
  }
408
507
 
409
508
  function resolveUser(
@@ -416,7 +515,7 @@ function resolveUser(
416
515
  return {
417
516
  qemuNetdevArgs: [
418
517
  "-netdev", netdevValue,
419
- ...deviceArgs(config.id),
518
+ ...deviceArgs(config),
420
519
  ],
421
520
  };
422
521
  }
@@ -438,7 +537,7 @@ function resolveShared(
438
537
  return {
439
538
  qemuNetdevArgs: [
440
539
  "-netdev", `socket,id=${config.id},fd=3`,
441
- ...deviceArgs(config.id),
540
+ ...deviceArgs(config),
442
541
  ],
443
542
  wrapper: [svn.client, svn.sharedSocket],
444
543
  };
@@ -446,7 +545,7 @@ function resolveShared(
446
545
  return {
447
546
  qemuNetdevArgs: [
448
547
  "-netdev", `vmnet-shared,id=${config.id}`,
449
- ...deviceArgs(config.id),
548
+ ...deviceArgs(config),
450
549
  ],
451
550
  downgraded: {
452
551
  from: "shared (socket_vmnet)",
@@ -473,7 +572,7 @@ function resolveVmnetShared(
473
572
  return {
474
573
  qemuNetdevArgs: [
475
574
  "-netdev", `vmnet-shared,id=${config.id}`,
476
- ...deviceArgs(config.id),
575
+ ...deviceArgs(config),
477
576
  ],
478
577
  };
479
578
  }
@@ -498,7 +597,7 @@ function resolveBridged(
498
597
  return {
499
598
  qemuNetdevArgs: [
500
599
  "-netdev", `socket,id=${config.id},fd=3`,
501
- ...deviceArgs(config.id),
600
+ ...deviceArgs(config),
502
601
  ],
503
602
  wrapper: [svn.client, bridgeSocket],
504
603
  };
@@ -506,7 +605,7 @@ function resolveBridged(
506
605
  return {
507
606
  qemuNetdevArgs: [
508
607
  "-netdev", `vmnet-bridged,id=${config.id},ifname=${resolved}`,
509
- ...deviceArgs(config.id),
608
+ ...deviceArgs(config),
510
609
  ],
511
610
  downgraded: {
512
611
  from: `bridged:${iface} (socket_vmnet)`,
@@ -534,14 +633,45 @@ function resolveVmnetBridged(
534
633
  return {
535
634
  qemuNetdevArgs: [
536
635
  "-netdev", `vmnet-bridged,id=${config.id},ifname=${iface}`,
537
- ...deviceArgs(config.id),
636
+ ...deviceArgs(config),
538
637
  ],
539
638
  };
540
639
  }
541
640
 
641
+ /** One-line description of the transport a named socket resolved to, for the line
642
+ * `start` prints and for `list`/`info`. The acceptance bar for #158's visibility
643
+ * work is that nothing about a named socket requires opening a file under the data
644
+ * dir, and this is what makes that true. */
645
+ export function describeSocketTransport(entry: SocketEntry, slot?: number): string {
646
+ switch (entry.mode) {
647
+ case "mcast":
648
+ return `UDP multicast ${entry.mcastGroup}:${entry.port} (N-way)`;
649
+ case "listen-connect":
650
+ if (slot === undefined) return `TCP pair on 127.0.0.1:${entry.port}`;
651
+ return slot === 0
652
+ ? `TCP 127.0.0.1:${entry.port} (listening)`
653
+ : `TCP 127.0.0.1:${entry.port} (connecting)`;
654
+ case "dgram": {
655
+ // Unoccupied: name the pair once rather than printing the directory twice.
656
+ if (slot === undefined) {
657
+ return `unix datagram ${socketEndpointPath(entry.name, 0).replace(/\.0\.sock$/, ".{0,1}.sock")}`;
658
+ }
659
+ const peer = slot === 0 ? 1 : 0;
660
+ return `unix datagram ${socketEndpointPath(entry.name, slot)} -> ${socketEndpointPath(entry.name, peer)}`;
661
+ }
662
+ }
663
+ }
664
+
665
+ /** `-netdev dgram` was added in QEMU 7.2. */
666
+ export function qemuSupportsDgram(version: string): boolean {
667
+ const [major = 0, minor = 0] = version.split(".").map((p) => Number.parseInt(p, 10) || 0);
668
+ return major > 7 || (major === 7 && minor >= 2);
669
+ }
670
+
542
671
  function resolveSocketNamed(
543
672
  config: NetworkConfig,
544
673
  name: string,
674
+ ctx: ResolutionContext,
545
675
  ): ResolvedNetwork {
546
676
  const entry = getNamedSocket(name);
547
677
  if (!entry) {
@@ -554,18 +684,60 @@ function resolveSocketNamed(
554
684
  return {
555
685
  qemuNetdevArgs: [
556
686
  "-netdev", `socket,id=${config.id},mcast=${entry.mcastGroup}:${entry.port}`,
557
- ...deviceArgs(config.id),
687
+ ...deviceArgs(config),
688
+ ],
689
+ };
690
+ }
691
+
692
+ // Two-member modes carry a role. It comes from the persisted slot, claimed when the
693
+ // machine joined, so a listener that stops and restarts comes back as the listener
694
+ // instead of silently demoting itself to a second connector.
695
+ const slot = ctx.machine ? getSocketSlot(entry, ctx.machine) : undefined;
696
+ if (slot === undefined) {
697
+ const held = entry.endpoints?.filter((m): m is string => m !== null) ?? [];
698
+ const who = held.length > 0 ? held.join(" and ") : "no machine";
699
+ throw new QuickCHRError(
700
+ "NETWORK_UNAVAILABLE",
701
+ ctx.machine
702
+ ? `Machine "${ctx.machine}" holds no endpoint on named socket "${name}" — it is a ${entry.mode} link, and its two ends are held by ${who}.`
703
+ : `Named socket "${name}" is a ${entry.mode} link, so resolving it needs the machine's name (its endpoint role is per-machine). Pass 'machine' in the resolution context.`,
704
+ );
705
+ }
706
+
707
+ if (entry.mode === "dgram") {
708
+ // Windows first: it cannot have this transport at any QEMU version.
709
+ if (ctx.platform.os === "win32") {
710
+ throw new QuickCHRError(
711
+ "NETWORK_UNAVAILABLE",
712
+ `Named socket "${name}" is a unix-datagram link, which Windows cannot provide (AF_UNIX has no SOCK_DGRAM). ` +
713
+ `Recreate it with 'quickchr networks sockets create ${name} --mode listen-connect'.`,
714
+ );
715
+ }
716
+ if (ctx.qemuVersion && !qemuSupportsDgram(ctx.qemuVersion)) {
717
+ throw new QuickCHRError(
718
+ "NETWORK_UNAVAILABLE",
719
+ `Named socket "${name}" is a unix-datagram link, which needs QEMU 7.2 or newer (found ${ctx.qemuVersion}). ` +
720
+ `Upgrade QEMU, or recreate the socket with 'quickchr networks sockets create ${name} --mode listen-connect'.`,
721
+ );
722
+ }
723
+ const local = socketEndpointPath(name, slot);
724
+ const remote = socketEndpointPath(name, slot === 0 ? 1 : 0);
725
+ return {
726
+ qemuNetdevArgs: [
727
+ "-netdev",
728
+ `dgram,id=${config.id},local.type=unix,local.path=${local},remote.type=unix,remote.path=${remote}`,
729
+ ...deviceArgs(config),
558
730
  ],
559
731
  };
560
732
  }
561
- const isFirst = entry.members.length === 0;
562
- const netdevArg = isFirst
733
+
734
+ const netdevArg = slot === 0
563
735
  ? `socket,id=${config.id},listen=:${entry.port}`
564
736
  : `socket,id=${config.id},connect=127.0.0.1:${entry.port}`;
565
737
  return {
566
738
  qemuNetdevArgs: [
567
739
  "-netdev", netdevArg,
568
- ...deviceArgs(config.id),
740
+ ...deviceArgs(config),
569
741
  ],
570
742
  };
571
743
  }
@@ -577,7 +749,7 @@ function resolveSocketListen(
577
749
  return {
578
750
  qemuNetdevArgs: [
579
751
  "-netdev", `socket,id=${config.id},listen=:${port}`,
580
- ...deviceArgs(config.id),
752
+ ...deviceArgs(config),
581
753
  ],
582
754
  };
583
755
  }
@@ -589,7 +761,7 @@ function resolveSocketConnect(
589
761
  return {
590
762
  qemuNetdevArgs: [
591
763
  "-netdev", `socket,id=${config.id},connect=127.0.0.1:${port}`,
592
- ...deviceArgs(config.id),
764
+ ...deviceArgs(config),
593
765
  ],
594
766
  };
595
767
  }
@@ -602,7 +774,7 @@ function resolveSocketMcast(
602
774
  return {
603
775
  qemuNetdevArgs: [
604
776
  "-netdev", `socket,id=${config.id},mcast=${group}:${port}`,
605
- ...deviceArgs(config.id),
777
+ ...deviceArgs(config),
606
778
  ],
607
779
  };
608
780
  }
@@ -621,7 +793,7 @@ function resolveTap(
621
793
  return {
622
794
  qemuNetdevArgs: [
623
795
  "-netdev", `tap,id=${config.id},ifname=${ifname},script=no,downscript=no`,
624
- ...deviceArgs(config.id),
796
+ ...deviceArgs(config),
625
797
  ],
626
798
  };
627
799
  }
@@ -650,7 +822,7 @@ export function resolveNetworkConfig(
650
822
  resolved = resolveVmnetBridged(config, spec.iface, ctx);
651
823
  break;
652
824
  case "socket":
653
- resolved = resolveSocketNamed(config, spec.name);
825
+ resolved = resolveSocketNamed(config, spec.name, ctx);
654
826
  break;
655
827
  case "socket-listen":
656
828
  resolved = resolveSocketListen(config, spec.port);
@@ -2,9 +2,9 @@
2
2
  * Extra package download and installation for CHR instances.
3
3
  */
4
4
 
5
- import { existsSync, readdirSync } from "node:fs";
5
+ import { existsSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
- import type { Arch } from "./types.ts";
7
+ import { QuickCHRError, type Arch } from "./types.ts";
8
8
  import { packagesDownloadUrl } from "./versions.ts";
9
9
  import { downloadToFile } from "./download.ts";
10
10
  import { getCacheDir, ensureDir } from "./state.ts";
@@ -13,6 +13,51 @@ import { restPost } from "./rest.ts";
13
13
  import { scpPush } from "./scp.ts";
14
14
  import { extractZip } from "./zip.ts";
15
15
 
16
+ const PACKAGE_CACHE_MANIFEST = ".quickchr-complete.json";
17
+
18
+ interface PackageCacheManifest {
19
+ version: string;
20
+ arch: Arch;
21
+ archiveSize: number;
22
+ packages: Array<{ name: string; size: number }>;
23
+ }
24
+
25
+ function packageFiles(extractDir: string): Array<{ name: string; size: number }> {
26
+ try {
27
+ return readdirSync(extractDir)
28
+ .filter((name) => name.endsWith(".npk"))
29
+ .map((name) => ({ name, size: statSync(join(extractDir, name)).size }))
30
+ .filter((entry) => entry.size > 0)
31
+ .sort((a, b) => a.name.localeCompare(b.name));
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ /** Verify the archive and every package recorded after atomic extraction. */
38
+ export async function isCompletePackageCache(
39
+ version: string,
40
+ arch: Arch,
41
+ cacheDir?: string,
42
+ ): Promise<boolean> {
43
+ const cache = cacheDir ?? getCacheDir();
44
+ const zipPath = join(cache, `all_packages-${arch}-${version}.zip`);
45
+ const extractDir = join(cache, `packages-${arch}-${version}`);
46
+ try {
47
+ const archiveSize = statSync(zipPath).size;
48
+ if (archiveSize <= 0) return false;
49
+ const manifest = await Bun.file(join(extractDir, PACKAGE_CACHE_MANIFEST)).json() as PackageCacheManifest;
50
+ const packages = packageFiles(extractDir);
51
+ return manifest.version === version &&
52
+ manifest.arch === arch &&
53
+ manifest.archiveSize === archiveSize &&
54
+ packages.length > 0 &&
55
+ JSON.stringify(manifest.packages) === JSON.stringify(packages);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
16
61
  /** Download and extract the all-packages ZIP for a version/arch. Returns the extract dir. */
17
62
  export async function downloadPackages(
18
63
  version: string,
@@ -28,7 +73,7 @@ export async function downloadPackages(
28
73
  const zipPath = join(cache, zipName);
29
74
  const extractDir = join(cache, `packages-${arch}-${version}`);
30
75
 
31
- if (existsSync(extractDir)) {
76
+ if (await isCompletePackageCache(version, arch, cache)) {
32
77
  const log = logger ?? createLogger();
33
78
  log.status(` Using cached packages: ${version} (${arch})`);
34
79
  return extractDir;
@@ -44,9 +89,38 @@ export async function downloadPackages(
44
89
  await downloadToFile(url, zipPath, { logger: log });
45
90
  }
46
91
 
47
- // Extract
48
- ensureDir(extractDir);
49
- extractZip(zipPath, extractDir);
92
+ // Extract into a sibling directory, record the exact complete set, then
93
+ // rename it into place. A killed extraction can never look like a cache hit.
94
+ const tempDir = mkdtempSync(join(cache, ".quickchr-packages-"));
95
+ try {
96
+ extractZip(zipPath, tempDir);
97
+ const packages = packageFiles(tempDir);
98
+ if (packages.length === 0) {
99
+ throw new Error(`No non-empty .npk files found in ${zipName}`);
100
+ }
101
+ const manifest: PackageCacheManifest = {
102
+ version,
103
+ arch,
104
+ archiveSize: statSync(zipPath).size,
105
+ packages,
106
+ };
107
+ await Bun.write(join(tempDir, PACKAGE_CACHE_MANIFEST), `${JSON.stringify(manifest, null, "\t")}\n`);
108
+ if (existsSync(extractDir)) rmSync(extractDir, { recursive: true, force: true });
109
+ renameSync(tempDir, extractDir);
110
+ } catch (error) {
111
+ rmSync(tempDir, { recursive: true, force: true });
112
+ if (error instanceof Error && error.message.startsWith("No non-empty")) {
113
+ throw new QuickCHRError("PROCESS_FAILED", error.message);
114
+ }
115
+ throw error;
116
+ }
117
+
118
+ if (!(await isCompletePackageCache(version, arch, cache))) {
119
+ throw new QuickCHRError(
120
+ "PROCESS_FAILED",
121
+ `Package cache verification failed for ${version} (${arch})`,
122
+ );
123
+ }
50
124
 
51
125
  return extractDir;
52
126
  }
@@ -67,12 +67,82 @@ async function waitForRest(
67
67
  }
68
68
  }
69
69
 
70
+ /** Wait until RouterOS actually accepts `auth`, returning how long that took.
71
+ *
72
+ * Deliberately *not* `waitForBoot()`: that one counts 401/403 as ready, because
73
+ * for a liveness probe an auth rejection still proves `www` is answering. Here
74
+ * a 401 is the thing being waited out, so it needs the opposite predicate.
75
+ *
76
+ * **This is hardening and an instrument, not a proven fix for #69.** The known
77
+ * symptom is a 401 on the first request made with freshly created credentials —
78
+ * four Windows CI legs across runs 35135008624 and 35386692051, always on the
79
+ * path that does no work between `createUser()` and that request. A local
80
+ * attempt to reproduce it found no window to close: on an Intel host against
81
+ * CHR 7.24.4 (x86 guest), a new user authenticated on attempt #1, 6/6, under
82
+ * both TCG and HVF. So the cause of the CI 401 is still open, and this wait
83
+ * does not claim to explain it.
84
+ *
85
+ * What it does buy: `createUser()` can no longer resolve while a `full`-group
86
+ * user's own credentials are rejected, and the outcome is measured either way.
87
+ * (Limited groups are exempt — see the table at the call site: a valid user
88
+ * without `rest-api` answers 401, which is indistinguishable from the symptom.) If the CI
89
+ * 401 is any kind of timing window, this closes it *and* the returned figure
90
+ * says how wide it was on that platform — which is the quantification #69 asks
91
+ * for. If it is not a timing window, the wait exhausts its budget and fails
92
+ * saying so, which is a far stronger signal than today's bare 401.
93
+ *
94
+ * Reports `attempts` alongside `elapsedMs`, and **`attempts` is the load-bearing
95
+ * number**. Elapsed time cannot tell a retry from a slow request: a first
96
+ * authentication with a new password costs a few hundred ms of ordinary request
97
+ * latency on an emulated guest, so an elapsed-only figure reads as a wait when
98
+ * nothing waited. `attempts === 1` means there was no window, whatever the
99
+ * clock says. (This is not hypothetical — it is the error the first pass at
100
+ * this investigation made; see `provisioning.instructions.md`.) */
101
+ export async function waitForAuth(
102
+ httpPort: number,
103
+ auth: string,
104
+ timeoutMs: number = 30_000,
105
+ ): Promise<{ attempts: number; elapsedMs: number }> {
106
+ const url = `http://127.0.0.1:${httpPort}/rest/system/resource`;
107
+ const start = Date.now();
108
+ const deadline = start + timeoutMs;
109
+ let lastStatus: number | undefined;
110
+ let attempts = 0;
111
+
112
+ while (Date.now() < deadline) {
113
+ attempts++;
114
+ // Bound each request by what is left of the budget, so a stalled request
115
+ // cannot push the whole wait past the deadline it advertises.
116
+ const remaining = deadline - Date.now();
117
+ try {
118
+ const { status } = await restGet(url, auth, Math.min(5_000, remaining));
119
+ if (status >= 200 && status < 300) return { attempts, elapsedMs: Date.now() - start };
120
+ // 401/403 is the propagation window. 5xx also occurs briefly after a
121
+ // user-database change, and is equally transient, so both are polled
122
+ // out rather than one being singled out as "the" expected status.
123
+ lastStatus = status;
124
+ } catch { /* transport error mid-transition — keep polling */ }
125
+ await Bun.sleep(250);
126
+ }
127
+
128
+ throw new QuickCHRError(
129
+ "PROCESS_FAILED",
130
+ `Credentials were not accepted within ${timeoutMs}ms over ${attempts} attempt(s) ` +
131
+ `(last HTTP status: ${lastStatus ?? "no response"}) — ` +
132
+ "the user record exists but RouterOS is not authenticating it",
133
+ );
134
+ }
135
+
70
136
  /** Create a user via the REST API. */
71
137
  export async function createUser(
72
138
  httpPort: number,
73
139
  name: string,
74
140
  password: string,
75
141
  group: string = "full",
142
+ logger?: ProgressLogger,
143
+ /** Budget for the post-visibility authentication wait. Injectable so tests
144
+ * can exercise the failure path without burning the 30 s default. */
145
+ authTimeoutMs: number = 30_000,
76
146
  ): Promise<void> {
77
147
  await waitForRest(httpPort);
78
148
 
@@ -95,6 +165,7 @@ export async function createUser(
95
165
  // Verify visibility (and group) so callers get deterministic behavior.
96
166
  const expectedGroup = group.trim().toLowerCase();
97
167
  const deadline = Date.now() + 30_000;
168
+ let visible = false;
98
169
  while (Date.now() < deadline) {
99
170
  try {
100
171
  const user = await readUser(httpPort, auth, name);
@@ -110,7 +181,8 @@ export async function createUser(
110
181
  `User "${name}" created with unexpected group (expected=${expectedGroup}, actual=${actualGroup})`,
111
182
  );
112
183
  }
113
- return;
184
+ visible = true;
185
+ break;
114
186
  }
115
187
  } catch (e) {
116
188
  // Rethrow deliberate group mismatch errors; retry transient HTTP failures
@@ -120,9 +192,58 @@ export async function createUser(
120
192
  await Bun.sleep(500);
121
193
  }
122
194
 
123
- throw new QuickCHRError(
124
- "PROCESS_FAILED",
125
- `User "${name}" creation was acknowledged but did not become visible in RouterOS`,
195
+ if (!visible) {
196
+ throw new QuickCHRError(
197
+ "PROCESS_FAILED",
198
+ `User "${name}" creation was acknowledged but did not become visible in RouterOS`,
199
+ );
200
+ }
201
+
202
+ // Only the `full` group is gated, because only there does "the probe answers
203
+ // 2xx" mean "the credentials work". RouterOS separates authentication from
204
+ // authorization, and a correctly created user in a limited group cannot
205
+ // answer this probe at all. Measured on CHR 7.24.4 by creating one user per
206
+ // group and issuing GET /rest/system/resource as that user:
207
+ //
208
+ // group policy | status
209
+ // ------------------------------------------|---------------------------
210
+ // local,ssh,winbox,read (no rest-api) | 401 Unauthorized
211
+ // local,ssh,winbox,rest-api,write (no read) | 500 std failure: not allowed (9)
212
+ // local,winbox,rest-api,read (no web) | 500 std failure: not allowed (9)
213
+ // read / write / full (defaults) | 200
214
+ //
215
+ // The 401 is byte-identical to #69's symptom, so no amount of polling can
216
+ // tell an unauthorized user from a credential that has not propagated, and the 500 is
217
+ // a permanent condition this wait would poll through to its deadline. Gating
218
+ // those would turn a correctly created user into a 30 s stall and a false
219
+ // "not authenticating" failure. `provision()` only ever creates `full`.
220
+ if (expectedGroup !== "full") {
221
+ logger?.debug(
222
+ `user "${name}" created in group "${group}" — authentication gate skipped: ` +
223
+ "only the full group is known to be REST-verifiable",
224
+ );
225
+ return;
226
+ }
227
+
228
+ // Deliberately outside the loop above. waitForAuth() throws a QuickCHRError,
229
+ // and that catch swallows every QuickCHRError except the group mismatch — so
230
+ // running it inside would discard the one diagnostic this wait exists to
231
+ // produce and report "did not become visible" for a user that plainly is.
232
+ //
233
+ // "Visible in /rest/user" was the whole done-when here, which is a weaker
234
+ // promise than every caller relies on: they use the credentials next.
235
+ const { attempts, elapsedMs } = await waitForAuth(
236
+ httpPort,
237
+ `Basic ${btoa(`${name}:${password}`)}`,
238
+ authTimeoutMs,
239
+ );
240
+ // attempts is the signal; elapsedMs alone cannot separate a retry from a slow
241
+ // first authentication on an emulated guest. The earlier attempts are only
242
+ // known to have not-succeeded — waitForAuth also polls through 5xx and
243
+ // transport errors — so they are not reported as rejections.
244
+ logger?.debug(
245
+ `user "${name}" accepted after ${attempts} attempt(s), ${elapsedMs}ms` +
246
+ (attempts > 1 ? ` — ${attempts - 1} earlier probe(s) did not succeed (#69)` : ""),
126
247
  );
127
248
  }
128
249
 
@@ -547,8 +668,9 @@ export async function installSshKey(
547
668
  /** Run all provisioning steps based on the config.
548
669
  *
549
670
  * When `user` is provided, creates that exact user. When `user` is omitted
550
- * but `secureLogin` is true (or unset — it defaults to true), a `quickchr`
551
- * managed account is created automatically with a generated password.
671
+ * and `secureLogin` is exactly `true`, a `quickchr` managed account is created
672
+ * automatically with a generated password; unset or `false` creates none — see
673
+ * `StartOptions.secureLogin`.
552
674
  *
553
675
  * When `machineDir` is provided and the REST API does not become available within
554
676
  * 30 seconds, provisioning falls back to the serial console transport.
@@ -609,7 +731,7 @@ export async function provision(
609
731
  // REST provisioning path. The console fallback above always returns early,
610
732
  // so reaching here means REST came up and we provision over it.
611
733
  if (effectiveUser) {
612
- await createUser(httpPort, effectiveUser.name, effectiveUser.password);
734
+ await createUser(httpPort, effectiveUser.name, effectiveUser.password, "full", log);
613
735
  // Persist to secret store so resolveAuth() picks it up
614
736
  saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
615
737
  }