@tikoci/quickchr 0.4.7 → 0.4.8
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 +366 -5
- package/MANUAL.md +199 -14
- package/README.md +29 -2
- package/package.json +4 -2
- package/src/cli/flags.ts +194 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +258 -34
- package/src/cli/tips.ts +57 -0
- package/src/index.ts +10 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/completions.ts +17 -3
- package/src/lib/images.ts +53 -22
- package/src/lib/names.ts +72 -0
- package/src/lib/network.ts +191 -19
- package/src/lib/packages.ts +80 -6
- package/src/lib/provision.ts +126 -5
- package/src/lib/qemu.ts +39 -12
- package/src/lib/quickchr.ts +248 -40
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +147 -6
- package/src/lib/types.ts +29 -5
package/src/lib/network.ts
CHANGED
|
@@ -20,7 +20,13 @@ import {
|
|
|
20
20
|
SERVICE_PORTS,
|
|
21
21
|
QuickCHRError,
|
|
22
22
|
} from "./types.ts";
|
|
23
|
-
import {
|
|
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. */
|
|
@@ -329,6 +335,9 @@ function parseSocketSpecifier(input: string): NetworkSpecifier {
|
|
|
329
335
|
"socket:: requires a link name (e.g. socket::mylink)",
|
|
330
336
|
);
|
|
331
337
|
}
|
|
338
|
+
// start() auto-creates a registry entry (and a networks/<name>.json file) for an
|
|
339
|
+
// unknown name, so the specifier is the other door into createNamedSocket().
|
|
340
|
+
assertValidResourceName(name, "named socket");
|
|
332
341
|
return { type: "socket", name };
|
|
333
342
|
}
|
|
334
343
|
|
|
@@ -388,6 +397,80 @@ export function resolveStartNetworks(
|
|
|
388
397
|
return [{ specifier: "user", id: "net0" }];
|
|
389
398
|
}
|
|
390
399
|
|
|
400
|
+
/** Derive a stable MAC for one NIC of one machine.
|
|
401
|
+
*
|
|
402
|
+
* QEMU's default is `52:54:00:12:34:56` incremented per NIC **within** a guest, so
|
|
403
|
+
* NIC index *N* holds the same address in *every* guest. Harmless while machines
|
|
404
|
+
* only have `user` NICs (separate SLIRP namespaces), fatal the moment two share an
|
|
405
|
+
* L2 segment — a named socket, a listen/connect pair, a TAP, or a RouterOS bridge
|
|
406
|
+
* on a hub VM. Forwarding then breaks in ways that look like a RouterOS or overlay
|
|
407
|
+
* defect rather than a launcher one (#154).
|
|
408
|
+
*
|
|
409
|
+
* The first octet is `0x02`: locally administered (bit 1 set) and unicast (bit 0
|
|
410
|
+
* clear), so these never collide with a vendor-assigned address. The remaining five
|
|
411
|
+
* octets come from a SHA-256 of the machine name, NIC index and `salt`, which makes
|
|
412
|
+
* the address reproducible for a given name instead of random per boot — the same
|
|
413
|
+
* lab rebuilt under the same names presents the same addresses.
|
|
414
|
+
*/
|
|
415
|
+
export function deriveMac(name: string, index: number, salt = 0): string {
|
|
416
|
+
const digest = new Bun.CryptoHasher("sha256")
|
|
417
|
+
.update(`${name}\u0000${index}\u0000${salt}`)
|
|
418
|
+
.digest();
|
|
419
|
+
// Five octets after the fixed `02`, so a NIC's address is 48 bits total.
|
|
420
|
+
const octets = Array.from(digest.subarray(0, 5), (o) =>
|
|
421
|
+
o.toString(16).padStart(2, "0"),
|
|
422
|
+
);
|
|
423
|
+
return ["02", ...octets].join(":");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Assign a MAC to every NIC that lacks one, avoiding `taken`.
|
|
427
|
+
*
|
|
428
|
+
* Mutates and returns `networks`. Idempotent: a NIC that already has a MAC keeps it,
|
|
429
|
+
* so a machine's addresses are fixed at creation and survive restart, a re-resolve on
|
|
430
|
+
* a different platform, and any later change to {@link deriveMac} itself.
|
|
431
|
+
*
|
|
432
|
+
* **Call this only where a machine is created.** Changing a MAC on a machine that has
|
|
433
|
+
* already booted breaks it: RouterOS ties its persisted interface identity to the
|
|
434
|
+
* address, so a new one orphans the `ether1` holding the DHCP client and the guest
|
|
435
|
+
* comes up with no IP. Verified by A/B on a live CHR — see DESIGN.md, "NIC MAC
|
|
436
|
+
* Addresses". Machines created before #154 keep QEMU's defaults and must be recreated.
|
|
437
|
+
*
|
|
438
|
+
* A SHA-256 collision across five octets is vanishingly unlikely, but `taken` makes
|
|
439
|
+
* the guarantee structural rather than probabilistic — a duplicate here is the exact
|
|
440
|
+
* silent-L2 failure this exists to prevent, so it is worth not leaving to odds.
|
|
441
|
+
*
|
|
442
|
+
* `taken` is a snapshot, so two concurrent creations can each miss the other. That
|
|
443
|
+
* only changes an outcome when the two would otherwise have collided — i.e. on top
|
|
444
|
+
* of that 2^-40 event — but the window is real. It is the same read-then-write
|
|
445
|
+
* window the port allocator has on the adjacent line (`getUsedPortBases()` →
|
|
446
|
+
* `findAvailablePortBlock()` → `saveMachine()`), where it fires routinely and
|
|
447
|
+
* breaks starts outright: #140. Both allocators want one lock around
|
|
448
|
+
* read-allocate-persist; adding a second, MAC-only lock here would leave the
|
|
449
|
+
* frequent half of the race unfixed while implying it was handled.
|
|
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
|
-
|
|
406
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
562
|
-
const netdevArg =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/src/lib/packages.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
49
|
-
|
|
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
|
}
|
package/src/lib/provision.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
|
@@ -609,7 +730,7 @@ export async function provision(
|
|
|
609
730
|
// REST provisioning path. The console fallback above always returns early,
|
|
610
731
|
// so reaching here means REST came up and we provision over it.
|
|
611
732
|
if (effectiveUser) {
|
|
612
|
-
await createUser(httpPort, effectiveUser.name, effectiveUser.password);
|
|
733
|
+
await createUser(httpPort, effectiveUser.name, effectiveUser.password, "full", log);
|
|
613
734
|
// Persist to secret store so resolveAuth() picks it up
|
|
614
735
|
saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
|
|
615
736
|
}
|