@tikoci/quickchr 0.4.8 → 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.
- package/DESIGN.md +162 -7
- package/MANUAL.md +273 -46
- package/README.md +13 -6
- package/package.json +1 -1
- package/src/cli/flags.ts +15 -1
- package/src/cli/index.ts +405 -76
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +2 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/completions.ts +20 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/lock.ts +497 -0
- package/src/lib/names.ts +24 -0
- package/src/lib/network.ts +16 -16
- package/src/lib/provision.ts +3 -2
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +54 -2
- package/src/lib/quickchr.ts +888 -327
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +2 -2
- package/src/lib/state.ts +64 -2
- package/src/lib/types.ts +96 -20
- package/src/lib/versions.ts +54 -0
package/src/cli/wizard.ts
CHANGED
|
@@ -81,6 +81,11 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
81
81
|
if (!/^\d+\.\d+(\.\d+)?(beta\d+|rc\d+)?$/.test(v)) {
|
|
82
82
|
return "Invalid version format";
|
|
83
83
|
}
|
|
84
|
+
// RouterOS 6 is a CLI-only boot target (#182) — the wizard offers
|
|
85
|
+
// provisioning at every step, none of which 6.x can take.
|
|
86
|
+
if (/^6\./.test(v)) {
|
|
87
|
+
return "RouterOS 6 is boot-only and not offered here — use: quickchr add --version <6.x> --arch x86";
|
|
88
|
+
}
|
|
84
89
|
},
|
|
85
90
|
});
|
|
86
91
|
if (clack.isCancel(ver)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
@@ -557,7 +562,8 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
557
562
|
const background = await clack.select({
|
|
558
563
|
message: "Run mode:",
|
|
559
564
|
options: [
|
|
560
|
-
|
|
565
|
+
// Not "returns immediately": a background start waits for RouterOS to be ready (#159).
|
|
566
|
+
{ value: true, label: "Background", hint: "console logged; returns when RouterOS is ready" },
|
|
561
567
|
{ value: false, label: "Foreground", hint: "serial console on stdio" },
|
|
562
568
|
],
|
|
563
569
|
initialValue: true,
|
|
@@ -566,8 +572,10 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
566
572
|
|
|
567
573
|
// 11. Confirm
|
|
568
574
|
const opts: StartOptions = {
|
|
575
|
+
// The channel has already been resolved into `version` above; passing both reads
|
|
576
|
+
// to start() as a caller asking for two releases.
|
|
569
577
|
version,
|
|
570
|
-
channel,
|
|
578
|
+
channel: version ? undefined : channel,
|
|
571
579
|
arch,
|
|
572
580
|
name: name || undefined,
|
|
573
581
|
cpu: Number(cpu),
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native API (`/login` on guest port 8728) as a readiness signal.
|
|
3
|
+
*
|
|
4
|
+
* Built for RouterOS 6, which has no REST API, so the REST probe can never succeed
|
|
5
|
+
* against it — the field report behind #182 watched a 6.49 guest answer www, ssh and
|
|
6
|
+
* api from 15 s in, then get removed at 180 s for not answering `/rest`. It is written
|
|
7
|
+
* as a general alternative to the REST probe rather than a v6 special case: a 7.x
|
|
8
|
+
* guest with REST disabled is the same problem.
|
|
9
|
+
*
|
|
10
|
+
* Only the post-6.43 plain login is spoken (`=name=` + `=password=` in one sentence).
|
|
11
|
+
* Grounded against CHR 6.49.23 under HVF, 2026-10-21:
|
|
12
|
+
*
|
|
13
|
+
* - `admin` / empty password → `!done`, as one 7-byte reply.
|
|
14
|
+
* - wrong user or password → `!trap =message=invalid user name or password (6)`
|
|
15
|
+
* followed by `!done`, in the same read.
|
|
16
|
+
*
|
|
17
|
+
* Either answer means the API service is up, the way a REST 401 does — the probe is a
|
|
18
|
+
* readiness check, not a credential check.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { connect } from "node:net";
|
|
22
|
+
|
|
23
|
+
/** Encode one API word length (RouterOS API "length" prefix, 1–5 bytes). */
|
|
24
|
+
export function encodeApiLength(length: number): Uint8Array {
|
|
25
|
+
if (length < 0x80) return Uint8Array.of(length);
|
|
26
|
+
if (length < 0x4000) return Uint8Array.of((length >> 8) | 0x80, length & 0xff);
|
|
27
|
+
if (length < 0x200000) return Uint8Array.of((length >> 16) | 0xc0, (length >> 8) & 0xff, length & 0xff);
|
|
28
|
+
if (length < 0x10000000) {
|
|
29
|
+
return Uint8Array.of((length >>> 24) | 0xe0, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff);
|
|
30
|
+
}
|
|
31
|
+
return Uint8Array.of(0xf0, (length >>> 24) & 0xff, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A sentence is its words, each length-prefixed, then a zero-length word. */
|
|
35
|
+
export function encodeApiSentence(words: string[]): Uint8Array {
|
|
36
|
+
const encoder = new TextEncoder();
|
|
37
|
+
const parts: Uint8Array[] = [];
|
|
38
|
+
for (const word of words) {
|
|
39
|
+
const bytes = encoder.encode(word);
|
|
40
|
+
parts.push(encodeApiLength(bytes.length), bytes);
|
|
41
|
+
}
|
|
42
|
+
parts.push(Uint8Array.of(0));
|
|
43
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
44
|
+
let offset = 0;
|
|
45
|
+
for (const part of parts) {
|
|
46
|
+
out.set(part, offset);
|
|
47
|
+
offset += part.length;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Decode a length prefix at `offset`. `null` when the buffer ends inside it. */
|
|
53
|
+
function decodeApiLength(buf: Uint8Array, offset: number): { length: number; size: number } | null {
|
|
54
|
+
const first = buf[offset];
|
|
55
|
+
if (first === undefined) return null;
|
|
56
|
+
const size = (first & 0x80) === 0 ? 1
|
|
57
|
+
: (first & 0xc0) === 0x80 ? 2
|
|
58
|
+
: (first & 0xe0) === 0xc0 ? 3
|
|
59
|
+
: (first & 0xf0) === 0xe0 ? 4
|
|
60
|
+
: 5;
|
|
61
|
+
if (offset + size > buf.length) return null;
|
|
62
|
+
let length = size === 5 ? 0 : first & (0xff >> size);
|
|
63
|
+
for (let i = 1; i < size; i++) length = length * 256 + (buf[offset + i] ?? 0);
|
|
64
|
+
return { length, size };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Every complete sentence in `buf`; a trailing partial sentence is left out. */
|
|
68
|
+
export function parseApiSentences(buf: Uint8Array): string[][] {
|
|
69
|
+
const decoder = new TextDecoder();
|
|
70
|
+
const sentences: string[][] = [];
|
|
71
|
+
let words: string[] = [];
|
|
72
|
+
let offset = 0;
|
|
73
|
+
while (offset < buf.length) {
|
|
74
|
+
const prefix = decodeApiLength(buf, offset);
|
|
75
|
+
if (!prefix || offset + prefix.size + prefix.length > buf.length) break;
|
|
76
|
+
offset += prefix.size;
|
|
77
|
+
if (prefix.length === 0) {
|
|
78
|
+
sentences.push(words);
|
|
79
|
+
words = [];
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
words.push(decoder.decode(buf.subarray(offset, offset + prefix.length)));
|
|
83
|
+
offset += prefix.length;
|
|
84
|
+
}
|
|
85
|
+
return sentences;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ApiLoginResult =
|
|
89
|
+
/** `!done` with no `!trap` — logged in. */
|
|
90
|
+
| { outcome: "done" }
|
|
91
|
+
/** `!trap` then `!done` — the service answered and refused the credentials. */
|
|
92
|
+
| { outcome: "trap"; message: string }
|
|
93
|
+
/** `!fatal` — the service answered and closed the session (e.g. too many sessions). */
|
|
94
|
+
| { outcome: "fatal"; message: string };
|
|
95
|
+
|
|
96
|
+
/** What the reply sentences say, once a `!done` or `!fatal` has arrived. `null` while
|
|
97
|
+
* the login is still unanswered. */
|
|
98
|
+
export function classifyLoginReply(sentences: string[][]): ApiLoginResult | null {
|
|
99
|
+
const messageOf = (sentence: string[]) =>
|
|
100
|
+
sentence.find((w) => w.startsWith("=message="))?.slice("=message=".length) ?? sentence.slice(1).join(" ");
|
|
101
|
+
const fatal = sentences.find((s) => s[0] === "!fatal");
|
|
102
|
+
if (fatal) return { outcome: "fatal", message: messageOf(fatal) };
|
|
103
|
+
if (!sentences.some((s) => s[0] === "!done")) return null;
|
|
104
|
+
const trap = sentences.find((s) => s[0] === "!trap");
|
|
105
|
+
return trap ? { outcome: "trap", message: messageOf(trap) } : { outcome: "done" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One `/login` round trip on `127.0.0.1:<port>`. Rejects on a socket error, on a close
|
|
109
|
+
* before the reply completes ("closed before reply"), and on the deadline
|
|
110
|
+
* ("apiLogin timeout") — the shapes `classifyProbeError()` sorts into outcomes. */
|
|
111
|
+
export function apiLogin(port: number, user: string, password: string, timeoutMs = 3000): Promise<ApiLoginResult> {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const socket = connect(port, "127.0.0.1");
|
|
114
|
+
let received = new Uint8Array(0);
|
|
115
|
+
let settled = false;
|
|
116
|
+
const finish = (fn: () => void) => {
|
|
117
|
+
if (settled) return;
|
|
118
|
+
settled = true;
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
socket.destroy();
|
|
121
|
+
fn();
|
|
122
|
+
};
|
|
123
|
+
const timer = setTimeout(() => finish(() => reject(new Error("apiLogin timeout"))), timeoutMs);
|
|
124
|
+
socket.on("connect", () => {
|
|
125
|
+
socket.write(encodeApiSentence(["/login", `=name=${user}`, `=password=${password}`]));
|
|
126
|
+
});
|
|
127
|
+
socket.on("data", (chunk: Uint8Array) => {
|
|
128
|
+
const next = new Uint8Array(received.length + chunk.length);
|
|
129
|
+
next.set(received);
|
|
130
|
+
next.set(chunk, received.length);
|
|
131
|
+
received = next;
|
|
132
|
+
const result = classifyLoginReply(parseApiSentences(received));
|
|
133
|
+
if (result) finish(() => resolve(result));
|
|
134
|
+
});
|
|
135
|
+
socket.on("error", (err) => finish(() => reject(err)));
|
|
136
|
+
socket.on("close", () => finish(() => reject(new Error("closed before reply"))));
|
|
137
|
+
});
|
|
138
|
+
}
|
package/src/lib/completions.ts
CHANGED
|
@@ -328,7 +328,7 @@ _quickchr() {
|
|
|
328
328
|
cword=${D}COMP_CWORD
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help"
|
|
331
|
+
local commands="add start stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help"
|
|
332
332
|
|
|
333
333
|
if [[ ${D}cword -eq 1 ]]; then
|
|
334
334
|
COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
|
|
@@ -337,12 +337,12 @@ _quickchr() {
|
|
|
337
337
|
|
|
338
338
|
local cmd="${D}{words[1]}"
|
|
339
339
|
case "${D}cmd" in
|
|
340
|
-
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
|
|
340
|
+
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
|
|
341
341
|
local machines
|
|
342
342
|
machines=$(quickchr completions --machines 2>/dev/null)
|
|
343
343
|
COMPREPLY=( $(compgen -W "${D}machines" -- "${D}cur") )
|
|
344
344
|
;;
|
|
345
|
-
stop)
|
|
345
|
+
stop|shutdown)
|
|
346
346
|
local machines
|
|
347
347
|
machines=$(quickchr completions --running 2>/dev/null)
|
|
348
348
|
[[ -z "${D}machines" ]] && machines=$(quickchr completions --machines 2>/dev/null)
|
|
@@ -387,12 +387,15 @@ _quickchr() {
|
|
|
387
387
|
local commands=(
|
|
388
388
|
'add:Create a new CHR machine (without starting)'
|
|
389
389
|
'start:Start or restart a CHR instance'
|
|
390
|
-
'stop:
|
|
390
|
+
'stop:Power cut instance(s)'
|
|
391
|
+
'shutdown:Request guest shutdown'
|
|
391
392
|
'list:List all instances'
|
|
392
393
|
'ls:List all instances (alias)'
|
|
393
394
|
'status:Detailed status'
|
|
394
395
|
'inspect:Print running machine descriptor'
|
|
395
396
|
'env:Print subprocess environment'
|
|
397
|
+
'get:Show live machine config (license, device-mode, admin)'
|
|
398
|
+
'set:Change a booted machine (license, device-mode)'
|
|
396
399
|
'remove:Remove instance(s) and disk'
|
|
397
400
|
'rm:Remove instance(s) (alias)'
|
|
398
401
|
'clean:Reset instance disk to fresh image'
|
|
@@ -418,12 +421,12 @@ _quickchr() {
|
|
|
418
421
|
args)
|
|
419
422
|
local cmd="${D}{words[2]}"
|
|
420
423
|
case ${D}cmd in
|
|
421
|
-
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
|
|
424
|
+
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env|get|set)
|
|
422
425
|
local machines
|
|
423
426
|
machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
|
|
424
427
|
_describe 'machine' machines
|
|
425
428
|
;;
|
|
426
|
-
stop)
|
|
429
|
+
stop|shutdown)
|
|
427
430
|
local machines
|
|
428
431
|
machines=(${D}{(f)"${D}(quickchr completions --running 2>/dev/null)"})
|
|
429
432
|
(( ${D}{#machines} == 0 )) && machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
|
|
@@ -444,7 +447,7 @@ _quickchr() {
|
|
|
444
447
|
_describe 'cache subcommand' subcommands
|
|
445
448
|
;;
|
|
446
449
|
help)
|
|
447
|
-
local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
|
|
450
|
+
local commands=(add start stop shutdown list status inspect env get set remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
|
|
448
451
|
_describe 'command' commands
|
|
449
452
|
;;
|
|
450
453
|
esac
|
|
@@ -460,7 +463,7 @@ function generateFishCompletion(): string {
|
|
|
460
463
|
return `# quickchr fish completion
|
|
461
464
|
# Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
|
|
462
465
|
|
|
463
|
-
set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
|
|
466
|
+
set -l quickchr_commands add start stop shutdown list ls status inspect env get set remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
|
|
464
467
|
|
|
465
468
|
# Disable file completions globally for quickchr
|
|
466
469
|
complete -c quickchr -f
|
|
@@ -471,7 +474,9 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
471
474
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
472
475
|
-a "start" -d "Start or restart a CHR instance"
|
|
473
476
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
474
|
-
-a "stop" -d "
|
|
477
|
+
-a "stop" -d "Power cut instance(s)"
|
|
478
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
479
|
+
-a "shutdown" -d "Request guest shutdown"
|
|
475
480
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
476
481
|
-a "list ls" -d "List all instances"
|
|
477
482
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
@@ -491,6 +496,10 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
491
496
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
492
497
|
-a "qga" -d "Guest agent command"
|
|
493
498
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
499
|
+
-a "get" -d "Show live machine config (license, device-mode, admin)"
|
|
500
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \
|
|
501
|
+
-a "set" -d "Change a booted machine (license, device-mode)"
|
|
502
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \
|
|
494
503
|
-a "license" -d "Apply/renew CHR trial license"
|
|
495
504
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
496
505
|
-a "disk" -d "Show disk details"
|
|
@@ -514,12 +523,12 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
514
523
|
-a "help" -d "Show help"
|
|
515
524
|
|
|
516
525
|
# Machine name completions for commands that take a running machine
|
|
517
|
-
complete -c quickchr -n "__fish_seen_subcommand_from stop" \\
|
|
526
|
+
complete -c quickchr -n "__fish_seen_subcommand_from stop shutdown" \\
|
|
518
527
|
-a "(quickchr completions --running 2>/dev/null; or quickchr completions --machines 2>/dev/null)" \\
|
|
519
528
|
-d "machine"
|
|
520
529
|
|
|
521
530
|
# Machine name completions for commands that take any machine
|
|
522
|
-
complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env" \\
|
|
531
|
+
complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env get set" \\
|
|
523
532
|
-a "(quickchr completions --machines 2>/dev/null)" \\
|
|
524
533
|
-d "machine"
|
|
525
534
|
|
package/src/lib/device-mode.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { DeviceModeOptions } from "./types.ts";
|
|
6
6
|
import { QuickCHRError } from "./types.ts";
|
|
7
|
+
import { shellQuote } from "./names.ts";
|
|
7
8
|
import { restGet, restPost } from "./rest.ts";
|
|
8
9
|
|
|
9
10
|
export const KNOWN_DEVICE_MODES = ["home", "advanced", "basic", "rose"] as const;
|
|
@@ -63,6 +64,26 @@ function normalizeFeatureList(list: string[] | undefined): string[] {
|
|
|
63
64
|
return [...new Set(values)];
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/** Keys `/system/device-mode` reports that are not feature flags. `mode` is the
|
|
68
|
+
* device-mode itself; the rest are status RouterOS keeps alongside it. */
|
|
69
|
+
export const DEVICE_MODE_NON_FEATURE_KEYS: ReadonlySet<string> = new Set([
|
|
70
|
+
"mode",
|
|
71
|
+
"allowed-versions",
|
|
72
|
+
"attempt-count",
|
|
73
|
+
"flagged",
|
|
74
|
+
"flagging-enabled",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/** Is this feature on, as RouterOS reports it?
|
|
78
|
+
*
|
|
79
|
+
* RouterOS answers `/system/device-mode` with **strings**: `"container": "true"`, not
|
|
80
|
+
* `true`. A reader that tests `value === true` therefore finds nothing enabled, ever —
|
|
81
|
+
* which is what `quickchr get <name> device-mode` did, printing a bare mode and no
|
|
82
|
+
* feature line however many were on. Normalize before deciding. */
|
|
83
|
+
export function isDeviceModeFeatureEnabled(value: unknown): boolean {
|
|
84
|
+
return toYesNo(value) === "yes";
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
function toYesNo(value: unknown): string {
|
|
67
88
|
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
68
89
|
if (typeof value === "number") return value > 0 ? "yes" : "no";
|
|
@@ -148,20 +169,61 @@ export function formatDeviceModeSelection(options: ResolvedDeviceModeOptions): s
|
|
|
148
169
|
return parts.join(" ");
|
|
149
170
|
}
|
|
150
171
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
172
|
+
/** Factory credentials — `admin` with no password, as a fresh CHR ships.
|
|
173
|
+
*
|
|
174
|
+
* The right auth for device-mode during *first-boot* provisioning, where it runs
|
|
175
|
+
* before any account has been created, and the wrong auth afterwards: a machine
|
|
176
|
+
* provisioned with `--disable-admin` answers 401. Post-boot callers resolve the
|
|
177
|
+
* machine's own credentials instead — see `applyDeviceMode`. */
|
|
178
|
+
export const FACTORY_AUTH_HEADER = `Basic ${btoa("admin:")}`;
|
|
179
|
+
|
|
180
|
+
/** Is this response body actually `/system/device-mode`, and not the post-boot race?
|
|
181
|
+
*
|
|
182
|
+
* RouterOS can answer a non-resource endpoint with resource-shaped or otherwise wrong
|
|
183
|
+
* data for a short window after boot — `provisioning.instructions.md` documents this
|
|
184
|
+
* for exactly this endpoint, and `readDeviceMode()` carries a guard for it. A 2xx is
|
|
185
|
+
* therefore not proof the endpoint is ready; a body with `mode` in it is. */
|
|
186
|
+
function isDeviceModeBody(body: string): boolean {
|
|
187
|
+
let data: unknown;
|
|
188
|
+
try { data = JSON.parse(body); } catch { return false; }
|
|
189
|
+
const record = Array.isArray(data) ? data[0] : data;
|
|
190
|
+
if (!record || typeof record !== "object") return false;
|
|
191
|
+
const keys = record as Record<string, unknown>;
|
|
192
|
+
if ("board-name" in keys || "architecture-name" in keys) return false;
|
|
193
|
+
return "mode" in keys;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Wait until the device-mode REST endpoint is reachable **and answering with
|
|
197
|
+
* device-mode data**.
|
|
198
|
+
*
|
|
199
|
+
* Returning on a bare 2xx was a race the next line then paid for: `readDeviceMode()`
|
|
200
|
+
* rejects a resource-shaped body outright, so a guest that came back mid-race failed
|
|
201
|
+
* the whole operation instead of being waited out for one more second. This is the
|
|
202
|
+
* poll-until-the-keys-are-there pattern `provisioning.instructions.md` prescribes for
|
|
203
|
+
* every non-resource endpoint, applied where the waiting already happens.
|
|
204
|
+
*
|
|
205
|
+
* `auth` defaults to factory admin, which is what a guest being provisioned for the
|
|
206
|
+
* first time has. A machine past that point needs its own credentials passed in. */
|
|
207
|
+
export async function waitForDeviceModeApi(
|
|
208
|
+
httpPort: number,
|
|
209
|
+
timeoutMs: number = 60_000,
|
|
210
|
+
auth: string = FACTORY_AUTH_HEADER,
|
|
211
|
+
): Promise<void> {
|
|
154
212
|
const deadline = Date.now() + timeoutMs;
|
|
213
|
+
let lastBody = "";
|
|
155
214
|
while (Date.now() < deadline) {
|
|
156
215
|
try {
|
|
157
|
-
const { status } = await restGet(
|
|
216
|
+
const { status, body } = await restGet(
|
|
158
217
|
`http://127.0.0.1:${httpPort}/rest/system/device-mode`,
|
|
159
218
|
auth,
|
|
160
219
|
3000,
|
|
161
220
|
);
|
|
162
221
|
|
|
163
222
|
if (status >= 200 && status < 300) {
|
|
164
|
-
return;
|
|
223
|
+
if (isDeviceModeBody(body)) return;
|
|
224
|
+
// Answering, but not with device-mode yet — keep waiting rather than
|
|
225
|
+
// handing the caller a body it will throw on.
|
|
226
|
+
lastBody = body;
|
|
165
227
|
}
|
|
166
228
|
|
|
167
229
|
if (status === 401) {
|
|
@@ -176,7 +238,11 @@ export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number =
|
|
|
176
238
|
await Bun.sleep(1000);
|
|
177
239
|
}
|
|
178
240
|
|
|
179
|
-
throw new QuickCHRError(
|
|
241
|
+
throw new QuickCHRError(
|
|
242
|
+
"BOOT_TIMEOUT",
|
|
243
|
+
`Device-mode API did not become ready on port ${httpPort}` +
|
|
244
|
+
(lastBody ? ` (last response was not device-mode data: ${lastBody.slice(0, 200)})` : ""),
|
|
245
|
+
);
|
|
180
246
|
}
|
|
181
247
|
|
|
182
248
|
/**
|
|
@@ -189,7 +255,11 @@ export async function waitForDeviceModeApi(httpPort: number, timeoutMs: number =
|
|
|
189
255
|
* Uses restPost (node:http + agent:false) to bypass Bun's connection pool.
|
|
190
256
|
* The 300s timeout is a safety limit — the caller is expected to kill QEMU well before then.
|
|
191
257
|
*/
|
|
192
|
-
export function startDeviceModeUpdate(
|
|
258
|
+
export function startDeviceModeUpdate(
|
|
259
|
+
httpPort: number,
|
|
260
|
+
options: ResolvedDeviceModeOptions,
|
|
261
|
+
auth: string = FACTORY_AUTH_HEADER,
|
|
262
|
+
): Promise<{ status: number; body: string }> {
|
|
193
263
|
if (!shouldApplyDeviceMode(options)) {
|
|
194
264
|
return Promise.resolve({ status: 200, body: "" });
|
|
195
265
|
}
|
|
@@ -200,7 +270,6 @@ export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceM
|
|
|
200
270
|
payload[name] = value;
|
|
201
271
|
}
|
|
202
272
|
|
|
203
|
-
const auth = `Basic ${btoa("admin:")}`;
|
|
204
273
|
return restPost(
|
|
205
274
|
`http://127.0.0.1:${httpPort}/rest/system/device-mode/update`,
|
|
206
275
|
auth,
|
|
@@ -209,8 +278,10 @@ export function startDeviceModeUpdate(httpPort: number, options: ResolvedDeviceM
|
|
|
209
278
|
);
|
|
210
279
|
}
|
|
211
280
|
|
|
212
|
-
export async function readDeviceMode(
|
|
213
|
-
|
|
281
|
+
export async function readDeviceMode(
|
|
282
|
+
httpPort: number,
|
|
283
|
+
auth: string = FACTORY_AUTH_HEADER,
|
|
284
|
+
): Promise<Record<string, string>> {
|
|
214
285
|
const { status, body } = await restGet(
|
|
215
286
|
`http://127.0.0.1:${httpPort}/rest/system/device-mode`,
|
|
216
287
|
auth,
|
|
@@ -281,3 +352,71 @@ export function verifyDeviceMode(
|
|
|
281
352
|
actual,
|
|
282
353
|
};
|
|
283
354
|
}
|
|
355
|
+
|
|
356
|
+
/** The `quickchr set` flags that would reproduce this selection, shell-quoted.
|
|
357
|
+
*
|
|
358
|
+
* Rendered from the *resolved* options, not the raw ones, so the command it prints
|
|
359
|
+
* is the command that was actually going to run — `mode: "auto"` comes out as the
|
|
360
|
+
* `rose` it resolves to rather than as an alias the reader has to expand.
|
|
361
|
+
*
|
|
362
|
+
* Quoted because `resolveDeviceModeOptions()` deliberately passes unknown modes and
|
|
363
|
+
* features through for forward compatibility (it warns, RouterOS validates), so a
|
|
364
|
+
* value with a space or a `;` in it reaches this function intact. Unquoted, the
|
|
365
|
+
* advertised command would silently become a different command. Ordinary values are
|
|
366
|
+
* returned unquoted, so the common case still reads like something a person typed. */
|
|
367
|
+
export function formatDeviceModeFlags(options: ResolvedDeviceModeOptions): string {
|
|
368
|
+
if (options.skip) return "";
|
|
369
|
+
const parts: string[] = [];
|
|
370
|
+
if (options.mode) parts.push(`--device-mode ${shellQuote(options.mode)}`);
|
|
371
|
+
const enable = Object.entries(options.features).filter(([, v]) => v === "yes").map(([k]) => k);
|
|
372
|
+
const disable = Object.entries(options.features).filter(([, v]) => v === "no").map(([k]) => k);
|
|
373
|
+
if (enable.length > 0) parts.push(`--device-mode-enable ${shellQuote(enable.sort().join(","))}`);
|
|
374
|
+
if (disable.length > 0) parts.push(`--device-mode-disable ${shellQuote(disable.sort().join(","))}`);
|
|
375
|
+
return parts.join(" ");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** What a device-mode update would change, read against the guest's current record.
|
|
379
|
+
*
|
|
380
|
+
* Only the differences — a change that costs a power cycle should say which settings
|
|
381
|
+
* it is buying, and `expected == actual` rows are noise. Empty means nothing would
|
|
382
|
+
* move, which is the caller's cue that no power cycle is needed. */
|
|
383
|
+
export function describeDeviceModeChange(
|
|
384
|
+
options: ResolvedDeviceModeOptions,
|
|
385
|
+
actual: Record<string, string>,
|
|
386
|
+
): string[] {
|
|
387
|
+
// `verifyDeviceMode` stays the single judge of what counts as a mismatch — only the
|
|
388
|
+
// key is read back out of its message, and the values come from the maps it returns.
|
|
389
|
+
const { expected, mismatches } = verifyDeviceMode(options, actual);
|
|
390
|
+
const changed = new Set(mismatches.map((mismatch) => mismatch.split(":")[0]));
|
|
391
|
+
return Object.entries(expected)
|
|
392
|
+
.filter(([key]) => changed.has(key))
|
|
393
|
+
.map(([key, want]) => `${key}: ${actual[key] ?? "(unset)"} -> ${want}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Fold an applied device-mode selection into what `machine.json` already records.
|
|
397
|
+
*
|
|
398
|
+
* `setDeviceMode()` used to overwrite `state.deviceMode` with the request, so
|
|
399
|
+
* `set <name> --device-mode-enable container` on a machine provisioned with
|
|
400
|
+
* `ipsec=yes` left the record claiming ipsec had never been asked for. The guest
|
|
401
|
+
* still had it: device-mode updates are cumulative, and only the settings named in
|
|
402
|
+
* a request move. The record has to be cumulative too, or it describes a machine
|
|
403
|
+
* that does not exist — and `classifyProvisioningRequest()` reads it. */
|
|
404
|
+
export function mergeDeviceModeOptions(
|
|
405
|
+
current: DeviceModeOptions | undefined,
|
|
406
|
+
applied: ResolvedDeviceModeOptions,
|
|
407
|
+
): DeviceModeOptions {
|
|
408
|
+
if (applied.skip) return current ?? {};
|
|
409
|
+
const enable = new Set(normalizeFeatureList(current?.enable));
|
|
410
|
+
const disable = new Set(normalizeFeatureList(current?.disable));
|
|
411
|
+
for (const [feature, value] of Object.entries(applied.features)) {
|
|
412
|
+
// A feature only ever sits on one side: the newest instruction for it wins.
|
|
413
|
+
enable.delete(feature);
|
|
414
|
+
disable.delete(feature);
|
|
415
|
+
(value === "yes" ? enable : disable).add(feature);
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
mode: applied.mode ?? current?.mode,
|
|
419
|
+
enable: enable.size > 0 ? [...enable].sort() : undefined,
|
|
420
|
+
disable: disable.size > 0 ? [...disable].sort() : undefined,
|
|
421
|
+
};
|
|
422
|
+
}
|
package/src/lib/diagnostics.ts
CHANGED
|
@@ -54,18 +54,22 @@ export type BootProbeOutcome =
|
|
|
54
54
|
| "reset"
|
|
55
55
|
/** Connect or response exceeded the per-probe budget — socket hung. */
|
|
56
56
|
| "probe-timeout"
|
|
57
|
-
/** 401/403
|
|
57
|
+
/** 401/403 from REST, or a login `!trap` from the native API — the service
|
|
58
|
+
* answered and refused the credentials; its auth layer is up. */
|
|
58
59
|
| "unauthorized"
|
|
59
60
|
/** 2xx but not the expected `board-name` singleton — REST still initializing. */
|
|
60
61
|
| "wrong-body"
|
|
61
62
|
/** Any other HTTP status. */
|
|
62
63
|
| "http-error"
|
|
63
|
-
/** 2xx with the expected body
|
|
64
|
+
/** 2xx with the expected body, or a native-API login `!done`. */
|
|
64
65
|
| "ready"
|
|
65
66
|
/** Anything unclassified — `detail` carries the raw text. */
|
|
66
67
|
| "other";
|
|
67
68
|
|
|
68
69
|
export interface BootProbeStats {
|
|
70
|
+
/** Which readiness probe ran — REST by default, the native-API login for a guest
|
|
71
|
+
* without REST (#182). Absent means REST. */
|
|
72
|
+
kind?: "rest" | "api";
|
|
69
73
|
/** Total probe attempts made. */
|
|
70
74
|
attempts: number;
|
|
71
75
|
/** Attempt count per outcome. */
|
|
@@ -90,9 +94,11 @@ export function classifyProbeError(err: unknown): { outcome: BootProbeOutcome; d
|
|
|
90
94
|
const message = err instanceof Error ? err.message : String(err);
|
|
91
95
|
if (code === "ECONNREFUSED") return { outcome: "refused", detail: code };
|
|
92
96
|
if (code === "ECONNRESET" || code === "EPIPE") return { outcome: "reset", detail: code };
|
|
93
|
-
if (code === "ETIMEDOUT" || message.includes("restGet timeout")) {
|
|
97
|
+
if (code === "ETIMEDOUT" || message.includes("restGet timeout") || message.includes("apiLogin timeout")) {
|
|
94
98
|
return { outcome: "probe-timeout", detail: code ?? "probe deadline" };
|
|
95
99
|
}
|
|
100
|
+
// apiLogin(): slirp accepted, then the guest side closed before any reply.
|
|
101
|
+
if (message === "closed before reply") return { outcome: "reset", detail: message };
|
|
96
102
|
return { outcome: "other", detail: code ? `${code}: ${message}` : message };
|
|
97
103
|
}
|
|
98
104
|
|
|
@@ -123,9 +129,10 @@ export function summarizeBootProbe(stats: BootProbeStats): string {
|
|
|
123
129
|
.sort((a, b) => b[1] - a[1])
|
|
124
130
|
.map(([k, v]) => `${k}=${v}`)
|
|
125
131
|
.join(" ");
|
|
132
|
+
const answer = stats.kind === "api" ? "API reply" : "HTTP response";
|
|
126
133
|
const reachedHttp = stats.firstHttpAtMs === null
|
|
127
|
-
?
|
|
128
|
-
: `first
|
|
134
|
+
? `forwarded port never produced an ${answer}`
|
|
135
|
+
: `first ${answer} at ${Math.round(stats.firstHttpAtMs / 1000)}s`;
|
|
129
136
|
const last = stats.last
|
|
130
137
|
? `last: ${stats.last.outcome}${stats.last.detail ? ` (${stats.last.detail})` : ""} at ${Math.round(stats.last.atMs / 1000)}s`
|
|
131
138
|
: "";
|
|
@@ -673,7 +680,7 @@ export async function captureBootFailure(ctx: BootFailureContext): Promise<BootF
|
|
|
673
680
|
lines.push(`Preceding credential transition: ${ctx.trigger.credentialTransition}`);
|
|
674
681
|
}
|
|
675
682
|
}
|
|
676
|
-
if (ctx.probe) lines.push(
|
|
683
|
+
if (ctx.probe) lines.push(`${ctx.probe.kind === "api" ? "API login" : "REST"} probe: ${summarizeBootProbe(ctx.probe)}`);
|
|
677
684
|
lines.push(`QEMU process: ${record.host.qemuProcess}`);
|
|
678
685
|
if (record.hostfwd) lines.push(`hostfwd 127.0.0.1:${record.hostfwd.port}: ${record.hostfwd.tcpConnect}`);
|
|
679
686
|
if (forwardProbe) lines.push(`slirp per-port: ${forwardProbe.summary}`);
|