@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.
- package/DESIGN.md +522 -6
- package/MANUAL.md +471 -59
- package/README.md +42 -8
- package/package.json +4 -2
- package/src/cli/flags.ts +208 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +660 -107
- package/src/cli/tips.ts +57 -0
- package/src/cli/wizard.ts +10 -2
- package/src/index.ts +12 -0
- package/src/lib/api-login.ts +138 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/completions.ts +34 -11
- package/src/lib/device-mode.ts +149 -10
- package/src/lib/diagnostics.ts +13 -6
- package/src/lib/images.ts +53 -22
- package/src/lib/lock.ts +497 -0
- package/src/lib/names.ts +96 -0
- package/src/lib/network.ts +199 -27
- package/src/lib/packages.ts +80 -6
- package/src/lib/provision.ts +129 -7
- package/src/lib/provisioning-window.ts +356 -0
- package/src/lib/qemu.ts +93 -14
- package/src/lib/quickchr.ts +1108 -339
- package/src/lib/shutdown.ts +49 -0
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +211 -8
- package/src/lib/types.ts +117 -17
- package/src/lib/versions.ts +54 -0
package/MANUAL.md
CHANGED
|
@@ -41,6 +41,10 @@ by the user (or their AI agent) using `quickchr` as a building block.
|
|
|
41
41
|
- ❌ Multi-router topology orchestration, test-matrix runners,
|
|
42
42
|
configuration-as-code DSLs. Use `examples/` as references and compose
|
|
43
43
|
yourself.
|
|
44
|
+
- ⚠️ **RouterOS 6** is a best-effort, boot-only target (x86, CLI only): quickchr
|
|
45
|
+
downloads, boots and tracks a 6.x CHR, and does nothing else to it. It exists so
|
|
46
|
+
v6→v7 migration tooling has a disposable 6.x router to test against — it is not
|
|
47
|
+
RouterOS 6 support. See [RouterOS 6](#routeros-6-boot-only) below.
|
|
44
48
|
|
|
45
49
|
**Two ways to use it:**
|
|
46
50
|
|
|
@@ -152,26 +156,39 @@ directory, downloads the RouterOS image (cached), and writes
|
|
|
152
156
|
| `--no-api-ssl` | false | Exclude API-SSL port |
|
|
153
157
|
| `--port-base <n>` | auto from 9100 | First port of the 10-port block |
|
|
154
158
|
|
|
155
|
-
`--vmnet-shared` and `--vmnet-bridge <iface>`
|
|
156
|
-
`--add-network shared`
|
|
159
|
+
`--vmnet-shared` and `--vmnet-bridge <iface>` have been removed. Use
|
|
160
|
+
`--add-network shared` and `--add-network bridged:<iface>`; either old spelling is
|
|
161
|
+
an error naming its replacement, not a silently ignored flag.
|
|
157
162
|
|
|
158
163
|
#### `start [name]`
|
|
159
164
|
|
|
160
165
|
Boot a stopped machine, or create-and-boot in one command. Blocks until
|
|
161
|
-
|
|
162
|
-
terminal (`--fg`). Accepts the same provisioning flags as `add`.
|
|
166
|
+
RouterOS is ready (REST answers; on 6.x, a native-API login), or attaches QEMU
|
|
167
|
+
stdio to your terminal (`--fg`). Accepts the same provisioning flags as `add`.
|
|
168
|
+
QEMU runs detached in its own session either way, so it survives a signal to the
|
|
169
|
+
shell that started it.
|
|
163
170
|
|
|
164
171
|
Additional flags:
|
|
165
172
|
|
|
166
173
|
| Flag | Notes |
|
|
167
174
|
|---|---|
|
|
168
|
-
| `--all` | Start every stopped machine in background |
|
|
169
|
-
| `--
|
|
175
|
+
| `--all` | Start every stopped machine in background; accepts readiness, acceleration, timeout and secure-login options only. Other options are refused rather than ignored |
|
|
176
|
+
| `--no-wait` | Return as soon as QEMU is running (past the 1.5 s early-exit check), with state and ports persisted; do not wait for RouterOS. Refused with any provisioning flag, including first-boot options `add` recorded, and with `--fg` |
|
|
170
177
|
| `--fg` / `--foreground` (alias `--no-bg`, `--no-background`) | Serial console on stdio |
|
|
178
|
+
| `--bg` / `--background` | **Deprecated, does nothing.** Background is the default, and the flag never made `start` return early — it only ever chose where the serial console went. Prints a warning; use `--no-wait` |
|
|
171
179
|
| `--install-deps` | Auto-install missing host deps (calls package manager) |
|
|
172
180
|
| `--timeout-extra <s>` / `-T <s>` | Add extra seconds to computed boot timeout |
|
|
173
181
|
| `--dry-run` | Print resolved options and QEMU command, exit |
|
|
174
182
|
|
|
183
|
+
After `--no-wait`, readiness is yours. centrs (0.1.8+) waits for it in one command:
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
quickchr start lab --no-wait
|
|
187
|
+
centrs retrieve --quickchr lab /system/resource --wait 5m
|
|
188
|
+
# RouterOS 6: centrs validates against the 7.x command tree, so skip validation
|
|
189
|
+
centrs retrieve --quickchr lab6 /system/resource --via native-api --no-validate --wait 5m
|
|
190
|
+
```
|
|
191
|
+
|
|
175
192
|
Foreground mode shortcuts (QEMU mux):
|
|
176
193
|
|
|
177
194
|
| Key | Action |
|
|
@@ -187,9 +204,74 @@ machine state.
|
|
|
187
204
|
|
|
188
205
|
#### `stop [name | --all]`
|
|
189
206
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
207
|
+
Power cut: sends SIGTERM to QEMU, waits up to 5 seconds, then SIGKILL if
|
|
208
|
+
needed. This does not request a guest shutdown; pending RouterOS changes
|
|
209
|
+
may be lost. Updates `state.status = "stopped"` and unregisters socket-named
|
|
210
|
+
networks. Listing-mode if no name and not `--all`.
|
|
211
|
+
|
|
212
|
+
Use `stop` for normal teardown and device-mode power-cycle confirmation.
|
|
213
|
+
Use `shutdown` when the caller wants to request a guest shutdown first.
|
|
214
|
+
|
|
215
|
+
#### `shutdown <name> [--timeout <seconds>]`
|
|
216
|
+
|
|
217
|
+
Sends `/system/shutdown` once through REST, independently waits for QEMU to
|
|
218
|
+
exit, then falls back to a power cut if it remains running. Default grace
|
|
219
|
+
budget: **10 seconds**. `--timeout` takes positive integer seconds and
|
|
220
|
+
overrides `QUICKCHR_SHUTDOWN_TIMEOUT`. Neither changes the boot timeout.
|
|
221
|
+
The maximum is 2,147,483 seconds (the HTTP timer's limit).
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
quickchr shutdown lab
|
|
225
|
+
quickchr shutdown lab --timeout 20
|
|
226
|
+
QUICKCHR_SHUTDOWN_TIMEOUT=20 quickchr shutdown lab
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The CLI reports `guest exited`, `already stopped`, or **forced power cut —
|
|
230
|
+
guest shutdown not confirmed**. Forced fallback exits successfully if QEMU
|
|
231
|
+
is confirmed gone; invalid input, lock failure, or inability to stop QEMU
|
|
232
|
+
is an error. Disks, credentials and restart configuration survive.
|
|
233
|
+
|
|
234
|
+
The guest request and exit observation share one grace budget. Lifecycle
|
|
235
|
+
lock acquisition, the existing SIGTERM/SIGKILL escalation, and final exit
|
|
236
|
+
verification have separate bounded waits. A 10-second grace budget does
|
|
237
|
+
not bound the entire CLI invocation to 10 seconds.
|
|
238
|
+
|
|
239
|
+
An HTTP error does not establish failure: on CHR 7.24.5, both x86/HVF and
|
|
240
|
+
arm64/TCG returned HTTP 400 `interrupted`, then exited. Shutdown uses
|
|
241
|
+
process exit as its completion signal. Observed exit does not guarantee
|
|
242
|
+
application or storage durability. Unavailable REST (including RouterOS 6),
|
|
243
|
+
failed authentication and a guest that does not halt all use the same
|
|
244
|
+
explicit hard-stop fallback; no console/QGA/ACPI replay is attempted.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
const result = await chr.shutdown({ timeoutMs: 20_000 });
|
|
248
|
+
// outcome: "guest-exited" | "forced" | "already-stopped"
|
|
249
|
+
// requestError can accompany guest-exited (e.g. HTTP 400 interrupted).
|
|
250
|
+
console.log(result.outcome);
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Library `timeoutMs` values are positive integer milliseconds, maximum
|
|
254
|
+
2,147,483,647, with a 10,000 ms default. Library callers supply options
|
|
255
|
+
explicitly; the CLI environment override is not read by `shutdown()`.
|
|
256
|
+
|
|
257
|
+
For consumers of an older quickchr version, the manual workaround needs
|
|
258
|
+
both request-error handling and independent exit observation:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
const pid = chr.state.pid;
|
|
262
|
+
if (pid) {
|
|
263
|
+
try { await chr.exec("/system/shutdown", { via: "rest", timeout: 10_000 }); }
|
|
264
|
+
catch { /* HTTP 400 interrupted may accompany successful guest shutdown. */ }
|
|
265
|
+
const exited = await chr.waitFor(async () => {
|
|
266
|
+
try { process.kill(pid, 0); return false; } catch { return true; }
|
|
267
|
+
}, 10_000);
|
|
268
|
+
if (!exited) console.warn("Guest shutdown not confirmed; forcing a power cut");
|
|
269
|
+
}
|
|
270
|
+
await chr.stop(); // reconcile state/network membership, or hard-stop if still alive
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
This older-version workaround has separate request and wait budgets;
|
|
274
|
+
`shutdown()` provides one shared budget and structured fallback reporting.
|
|
193
275
|
|
|
194
276
|
#### `remove [name | --all]` (alias `rm`)
|
|
195
277
|
|
|
@@ -206,10 +288,17 @@ flag. The next `start` therefore logs in as factory `admin` with an empty
|
|
|
206
288
|
password. EFI vars are deliberately **kept** (they store boot order, not
|
|
207
289
|
guest state; wiping them costs an arm64 device rescan).
|
|
208
290
|
|
|
209
|
-
`clean`
|
|
210
|
-
(
|
|
211
|
-
|
|
212
|
-
|
|
291
|
+
`clean` itself never provisions — it only resets the disk. What it does do is
|
|
292
|
+
**reopen the provisioning window** (#176), so the *next* `start` re-applies the
|
|
293
|
+
provisioning intent `machine.json` still holds: `packages`, `deviceMode`,
|
|
294
|
+
`secureLogin`. Budget for that start accordingly; it costs what a first boot costs,
|
|
295
|
+
not what a restart costs.
|
|
296
|
+
|
|
297
|
+
What does **not** come back on its own is anything `clean` had to clear because it
|
|
298
|
+
described the erased guest rather than an intent — `user`, `disableAdmin`,
|
|
299
|
+
`licenseLevel` — and any option from a request that was *refused*, since a refused
|
|
300
|
+
request is never persisted. Pass those flags again, which is why the refusal says to
|
|
301
|
+
repeat the original command rather than to run a bare `start`.
|
|
213
302
|
|
|
214
303
|
### Inspection
|
|
215
304
|
|
|
@@ -219,11 +308,27 @@ Without a name: tabular summary of every machine. With a name: detailed
|
|
|
219
308
|
view including credentials and connection tips. `--json` for
|
|
220
309
|
machine-readable output.
|
|
221
310
|
|
|
311
|
+
A machine whose `machine.json` cannot be read — unreadable on disk, not valid JSON, or
|
|
312
|
+
valid JSON that is not usable machine state — still appears, as a row marked
|
|
313
|
+
`unreadable` followed by `quickchr remove <name>`. A `saveMachine()` interrupted by a
|
|
314
|
+
disk-full or a power loss is the usual cause. The listing exits 0: it succeeded, and the
|
|
315
|
+
broken entry is named rather than silently dropped. In `--json` that entry is
|
|
316
|
+
`{"name": …, "status": "unreadable", "error": …}` with no machine fields.
|
|
317
|
+
|
|
318
|
+
Naming that machine in a command that has to *use* its state — `list <name>`, `stop`,
|
|
319
|
+
`clean`, `env` — fails with `STATE_ERROR` naming the file, because a lookup you asked
|
|
320
|
+
for by name is not an enumeration. `quickchr remove <name>` is the exception, and has to
|
|
321
|
+
be: it is the command whose job is to clear that state, so it treats an unreadable
|
|
322
|
+
machine as a directory to delete rather than state to load.
|
|
323
|
+
|
|
222
324
|
#### `get <name> [license | device-mode | admin] [--json]`
|
|
223
325
|
|
|
224
326
|
Live REST query. Hits the running CHR and returns the requested group
|
|
225
327
|
(or all if no group specified). Requires the machine to be running.
|
|
226
328
|
|
|
329
|
+
`device-mode` lists the mode plus every feature RouterOS reports, split into
|
|
330
|
+
enabled and disabled — the read-back for `quickchr set <name> --device-mode…`.
|
|
331
|
+
|
|
227
332
|
#### `inspect <name> [--json]`
|
|
228
333
|
|
|
229
334
|
Stable, versioned (`descriptorVersion: 1`) connection descriptor for a
|
|
@@ -265,8 +370,9 @@ Tail the per-machine `qemu.log`. `--follow` streams new output;
|
|
|
265
370
|
|
|
266
371
|
`networks` enumerates host interfaces and their alias resolution
|
|
267
372
|
(`wifi`, `ethernet`, `auto`). `networks sockets` lists registered
|
|
268
|
-
named L2 sockets; `networks sockets create <name>`
|
|
269
|
-
new
|
|
373
|
+
named L2 sockets and the transport each one uses; `networks sockets create <name>`
|
|
374
|
+
registers a new link, taking `--mode` to pick that transport (the default needs no
|
|
375
|
+
port at all).
|
|
270
376
|
|
|
271
377
|
#### `version [--json]`
|
|
272
378
|
|
|
@@ -313,11 +419,43 @@ Apply or renew a CHR trial license on a running instance. Resolves
|
|
|
313
419
|
MikroTik.com credentials from `--license-account`/`--license-password`,
|
|
314
420
|
the env vars, or the secret store. Requires RouterOS ≥ 7.20.8.
|
|
315
421
|
|
|
316
|
-
#### `set <name> <
|
|
422
|
+
#### `set <name> [--license | --device-mode <m>] [--device-mode-enable <f>] [--device-mode-disable <f>]`
|
|
317
423
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
424
|
+
Change a property on a machine that has **already booted** — the post-boot half
|
|
425
|
+
of the provisioning flags. `add`/`start` apply them at first boot; `set` applies
|
|
426
|
+
them now. It is the route `PROVISIONING_WINDOW_CLOSED` names, printed with the
|
|
427
|
+
flags that were asked for so it can be run as given.
|
|
428
|
+
|
|
429
|
+
| flag | what it does |
|
|
430
|
+
|---|---|
|
|
431
|
+
| `--license` | Apply or renew a CHR trial license (§ `license` above for credential resolution). |
|
|
432
|
+
| `--device-mode <m>` | `rose`, `advanced`, `basic`, `home`, `auto`. |
|
|
433
|
+
| `--device-mode-enable <f,…>` | Set one or more device-mode flags to yes. Repeatable, comma-separated. |
|
|
434
|
+
| `--device-mode-disable <f,…>` | Set one or more to no. |
|
|
435
|
+
|
|
436
|
+
**Device-mode restarts the machine.** RouterOS only applies a device-mode change
|
|
437
|
+
across a power cycle, and quickchr owns the power button — which is why this lives
|
|
438
|
+
here rather than in a tool that reaches a router over the network: the same request
|
|
439
|
+
issued from inside the guest never returns, because it is waiting for a power cycle
|
|
440
|
+
it cannot perform on itself. quickchr prints every setting that will move before it
|
|
441
|
+
restarts, and skips the restart entirely when the guest already matches.
|
|
442
|
+
|
|
443
|
+
Naming a feature without a mode resolves the mode to `rose` — so
|
|
444
|
+
`set lab --device-mode-enable container` moves `mode` as well. That move is printed
|
|
445
|
+
before it happens, not discovered afterwards.
|
|
446
|
+
|
|
447
|
+
Preconditions: the machine must be **running** and have a **user-mode NIC**
|
|
448
|
+
(device-mode is applied over localhost REST), on RouterOS ≥ 7.20.8. Each is a
|
|
449
|
+
named refusal rather than a timeout.
|
|
450
|
+
|
|
451
|
+
The change is folded into `machine.json` rather than overwriting it — device-mode
|
|
452
|
+
updates move only the settings they name, so the record stays cumulative — and
|
|
453
|
+
recorded in `provisioning.steps`, which makes a later `start` passing the same
|
|
454
|
+
device-mode a recognised no-op instead of a refusal.
|
|
455
|
+
|
|
456
|
+
**Not available here:** `--add-user`, `--disable-admin`, `--secure-login`. Those
|
|
457
|
+
rewrite working config rather than unlock a capability, and they are the steps that
|
|
458
|
+
drift; their route stays `quickchr clean <name>` followed by the original command.
|
|
321
459
|
|
|
322
460
|
#### `snapshot <name> <list | save [snapname] | load <snapname> | delete <snapname>>` (alias `snap`)
|
|
323
461
|
|
|
@@ -357,7 +495,7 @@ another machine, view status, or quit.
|
|
|
357
495
|
| 9 | Extra blank disks (repeatable) | none | `--add-disk` |
|
|
358
496
|
| 10 | Additional networks (macOS/Linux) | user-mode only | `--add-network` |
|
|
359
497
|
| 11 | Provisioning (packages, device-mode, user, license) | optional | various |
|
|
360
|
-
| 12 | Run mode: background / foreground | background |
|
|
498
|
+
| 12 | Run mode: background / foreground | background | (default) / `--fg` |
|
|
361
499
|
| 13 | Final confirmation | — | — |
|
|
362
500
|
|
|
363
501
|
Steps 7–9 are skipped when `qemu-img` is absent (a warning is shown). Step 10
|
|
@@ -392,6 +530,43 @@ shell completions if not yet configured.
|
|
|
392
530
|
|
|
393
531
|
Print or install shell completions. With no arg, detects current shell.
|
|
394
532
|
|
|
533
|
+
#### `cache [add|key|list|prune|clear]`
|
|
534
|
+
|
|
535
|
+
Manage downloaded CHR images. `cache add` resolves and downloads an image only;
|
|
536
|
+
it does not check QEMU, create a machine, or boot RouterOS. A cache hit is an
|
|
537
|
+
idempotent success.
|
|
538
|
+
|
|
539
|
+
```bash
|
|
540
|
+
quickchr cache add --channel stable --arch x86
|
|
541
|
+
quickchr cache add --version 7.24.4 --arch arm64 --json
|
|
542
|
+
quickchr cache key --channel stable --arch x86
|
|
543
|
+
# dir=/Users/me/.local/share/quickchr/cache
|
|
544
|
+
# version=7.24.4
|
|
545
|
+
# arch=x86
|
|
546
|
+
quickchr cache key --version 7.24.4 --arch x86 --json
|
|
547
|
+
# {"dir":".../quickchr/cache","version":"7.24.4","arch":"x86"}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
`--channel` and `--version` are mutually exclusive. When neither is supplied,
|
|
551
|
+
`default-channel` applies; both subcommands use `default-arch`. A pinned
|
|
552
|
+
version makes no network request in `cache key`. If channel resolution is
|
|
553
|
+
offline, `cache key` prints the stable `unresolved` sentinel and exits 0 so a CI
|
|
554
|
+
cache lookup cannot red the job; `cache add` fails because it cannot download an
|
|
555
|
+
unknown image. Without `--json`, `cache key` emits `key=value` lines that can be
|
|
556
|
+
appended directly to `$GITHUB_OUTPUT`.
|
|
557
|
+
|
|
558
|
+
```yaml
|
|
559
|
+
- id: chr-cache
|
|
560
|
+
run: quickchr cache key --channel stable --arch x86 >> "$GITHUB_OUTPUT"
|
|
561
|
+
- uses: actions/cache/restore@v5
|
|
562
|
+
with:
|
|
563
|
+
path: ${{ steps.chr-cache.outputs.dir }}
|
|
564
|
+
key: chr-${{ runner.os }}-${{ steps.chr-cache.outputs.version }}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
`--json` intentionally emits one JSON object for programmatic parsing; use the
|
|
568
|
+
default output form when writing step outputs directly.
|
|
569
|
+
|
|
395
570
|
#### `settings [print|get|set|reset] [key] [value] [--json]`
|
|
396
571
|
|
|
397
572
|
Manage quickchr's own global preferences — repeated flags/defaults that
|
|
@@ -445,10 +620,11 @@ mutate any machine's `machine.json`.
|
|
|
445
620
|
| `QUICKCHR_NO_PROMPT` | Force non-interactive mode (skips all wizards) |
|
|
446
621
|
| `QUICKCHR_DEBUG` | Emit `[debug]` lines from the progress logger |
|
|
447
622
|
| `QUICKCHR_INTEGRATION` | Required to run `test/integration/` |
|
|
448
|
-
| `QUICKCHR_PRESERVE_ON_FAILURE` | `=1`
|
|
623
|
+
| `QUICKCHR_PRESERVE_ON_FAILURE` | `=1` skips removal/reset after a first-boot timeout or failed first-boot provisioning run (QEMU is still stopped). Previously booted machines always survive a restart timeout |
|
|
449
624
|
| `QUICKCHR_SERIAL_LOG` | `=1` tees the guest serial console to `<machineDir>/serial.log`. **Secret-bearing** — serial provisioning types the generated password in cleartext |
|
|
450
625
|
| `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `=1` lets a boot-timeout capture write to the guest (a mangle counting rule + host probes) to localize a silent packet drop. Skipped when `QUICKCHR_PRESERVE_ON_FAILURE=1` |
|
|
451
626
|
| `MIKROTIK_WEB_ACCOUNT`, `MIKROTIK_WEB_PASSWORD` | License credentials fallback |
|
|
627
|
+
| `QUICKCHR_NO_TIPS` | `=1` suppresses the dim `tip:` lines quickchr prints on stderr |
|
|
452
628
|
| `NO_COLOR` | Disable ANSI styling |
|
|
453
629
|
|
|
454
630
|
`--json` is honored on `list`, `status`, `get`, `env`, `snapshot`, and a
|
|
@@ -456,6 +632,26 @@ few others; `inspect` is always JSON and accepts `--json` for parity. Check
|
|
|
456
632
|
`quickchr help <command>`. Errors are caught at the top level and exit `1`
|
|
457
633
|
with a `[code] message` line plus the install hint when present.
|
|
458
634
|
|
|
635
|
+
**Help.** `--help` / `-h` on any subcommand prints that subcommand's help, exits `0`,
|
|
636
|
+
and touches no state — including `quickchr add --help`, which is a machine-creating
|
|
637
|
+
command. Anything after a bare `--` is your payload and is never scanned for flags:
|
|
638
|
+
|
|
639
|
+
```bash
|
|
640
|
+
quickchr exec my-chr -- ":put [/system/resource/get version]"
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
**Unknown flags.** `add` and `start` reject a flag they do not know rather than
|
|
644
|
+
ignoring it, and suggest the closest match. The read-only commands still tolerate
|
|
645
|
+
stray arguments.
|
|
646
|
+
|
|
647
|
+
**Names.** Machine and named-socket names may contain letters, digits, `.`, `_`
|
|
648
|
+
and `-`, must start with a letter or digit, and are capped at 64 characters. Both
|
|
649
|
+
become a directory or file name under the data dir (§8).
|
|
650
|
+
|
|
651
|
+
**Tips.** quickchr prints an occasional one-line `tip:` on **stderr** pointing at a
|
|
652
|
+
better-suited command or tool. They never go to stdout, so piping `--json` output is
|
|
653
|
+
unaffected. `QUICKCHR_NO_TIPS=1` turns them off.
|
|
654
|
+
|
|
459
655
|
---
|
|
460
656
|
|
|
461
657
|
## 4. Library API reference
|
|
@@ -471,15 +667,37 @@ QuickCHR.start(opts?: StartOptions): Promise<ChrInstance>
|
|
|
471
667
|
QuickCHR.add(opts?: StartOptions): Promise<MachineState>
|
|
472
668
|
QuickCHR.list(): MachineState[]
|
|
473
669
|
QuickCHR.get(name: string): ChrInstance | null
|
|
670
|
+
QuickCHR.listUnreadable(): Array<{ name: string; error: string }>
|
|
671
|
+
QuickCHR.listOrphans(): string[]
|
|
672
|
+
QuickCHR.removeOrphan(name: string): boolean
|
|
474
673
|
QuickCHR.doctor(): Promise<DoctorResult>
|
|
475
674
|
QuickCHR.resolveVersion(channel: Channel): Promise<string>
|
|
476
675
|
```
|
|
477
676
|
|
|
478
677
|
`start()` creates the machine if needed, downloads the image, allocates
|
|
479
|
-
a port block, spawns QEMU, waits for REST
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
678
|
+
a port block, spawns QEMU, waits for readiness (REST on 7.x, a native-API
|
|
679
|
+
`/login` on RouterOS 6), and runs all provisioning steps before returning.
|
|
680
|
+
With `wait: false` (`--no-wait`) it returns once QEMU is running and leaves
|
|
681
|
+
readiness to the caller; provisioning options are refused in that mode.
|
|
682
|
+
On a boot timeout it stops QEMU, then removes a machine this call created,
|
|
683
|
+
resets an existing never-booted machine to its factory disk, and preserves a
|
|
684
|
+
previously booted one — see [Auto-cleanup on boot timeout](#auto-cleanup-on-boot-timeout).
|
|
685
|
+
|
|
686
|
+
**Concurrency.** Machines may be created and started in parallel, from one
|
|
687
|
+
`Promise.all` or from separate processes. Each creation chooses its port
|
|
688
|
+
block and NIC MACs under a data-dir-wide allocation lock and reserves them
|
|
689
|
+
until `machine.json` is written, so concurrent auto-allocated machines never
|
|
690
|
+
share a block. An explicit `portBase` is never moved: one that overlaps
|
|
691
|
+
another machine's block fails with `PORT_CONFLICT`. Per machine, every
|
|
692
|
+
lifecycle operation takes the machine lock (`machines/<name>/.lock/`):
|
|
693
|
+
|
|
694
|
+
- `stop()`, `clean()` and `remove()` **wait** for an operation already in
|
|
695
|
+
progress (up to the machine's boot timeout), then act on the state it left.
|
|
696
|
+
- A second `start()` of the same machine, `setDeviceMode()` and removing a
|
|
697
|
+
leftover directory **fail at once** with `MACHINE_LOCKED`, naming the
|
|
698
|
+
operation, pid and age of the holder.
|
|
699
|
+
- A machine running in the **foreground** holds the lock until its VM exits.
|
|
700
|
+
Nothing waits for it. Quit it from its console.
|
|
483
701
|
|
|
484
702
|
`add()` is the same minus the QEMU spawn — useful when you want to
|
|
485
703
|
materialize a machine and start it later (or hand the config to another
|
|
@@ -489,6 +707,31 @@ process).
|
|
|
489
707
|
loads `machine.json`, refreshes PID liveness, and returns a runtime
|
|
490
708
|
handle. `list()` is `get()` for everything in `machines/`.
|
|
491
709
|
|
|
710
|
+
`listOrphans()` / `removeOrphan()` cover the gap between the two: a directory under
|
|
711
|
+
`machines/` with no readable `machine.json`, left by a create that a crash or a
|
|
712
|
+
SIGKILL interrupted. It still occupies the name, so `add()` refuses it, but no
|
|
713
|
+
machine answers to it.
|
|
714
|
+
|
|
715
|
+
How `get()` and `list()` behave depends on *why* the state is unreadable, and the
|
|
716
|
+
two differ — a targeted lookup fails, an enumeration does not:
|
|
717
|
+
|
|
718
|
+
| State | `get(name)` | `list()` |
|
|
719
|
+
|---|---|---|
|
|
720
|
+
| `machine.json` missing | `null` | omits it |
|
|
721
|
+
| `machine.json` present but unreadable (read error, invalid JSON, or valid JSON that is not machine state) | **throws** `STATE_ERROR`, naming the file and `quickchr remove <name>` | skips it; `listUnreadable()` names it, and `quickchr list` shows it as a row |
|
|
722
|
+
|
|
723
|
+
`listUnreadable()` returns `{ name, error }` for every machine in the second row — the
|
|
724
|
+
file is present but cannot be turned into state, whether because the read failed, the
|
|
725
|
+
JSON is invalid, or the parsed value is not a machine — so nothing is dropped without
|
|
726
|
+
being reported somewhere. A
|
|
727
|
+
directory with *no* `machine.json` is not in it; that is a create that never finished,
|
|
728
|
+
and `listOrphans()` covers it.
|
|
729
|
+
|
|
730
|
+
`listOrphans()` and `removeOrphan()` treat both the same — unreadable is unreadable —
|
|
731
|
+
so `quickchr remove <name>` clears either. `removeOrphan()` returns `false` for a real
|
|
732
|
+
machine (use `get(name)?.remove()` for those) and for a name that does not exist, and
|
|
733
|
+
throws `INVALID_NAME` for a name that is not a usable path segment.
|
|
734
|
+
|
|
492
735
|
### `ChrInstance`
|
|
493
736
|
|
|
494
737
|
**At a glance** — every method on a running instance:
|
|
@@ -496,8 +739,8 @@ handle. `list()` is `get()` for everything in `machines/`.
|
|
|
496
739
|
| Group | Member | Purpose |
|
|
497
740
|
|---|---|---|
|
|
498
741
|
| Identity | `name`, `state`, `ports`, `restUrl`, `sshPort`, `portBase` | Static metadata persisted in `machine.json` |
|
|
499
|
-
| Capture | `captureInterface`, `
|
|
500
|
-
| Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, teardown |
|
|
742
|
+
| Capture | `captureInterface`, `hostGatewayIp` | Platform-correct values for TZSP capture (`tshark -i …`) and the guest→host address any UDP emitter targets |
|
|
743
|
+
| Lifecycle | `waitForBoot()`, `waitFor(fn, ms?)`, `stop()`, `shutdown(opts?)`, `remove()`, `clean()`, `destroy()` | Boot readiness, custom polling, power-cut or guest-shutdown teardown |
|
|
501
744
|
| Comms | `rest()`, `exec()`, `monitor()`, `serial()`, `qga()` | REST, RouterOS CLI, QEMU monitor, serial console, QGA (x86 only) |
|
|
502
745
|
| Provisioning | `license()`, `setDeviceMode()`, `availablePackages()`, `installPackage()` | Post-boot configuration |
|
|
503
746
|
| Files | `upload()`, `download()` | SCP transfer to/from the CHR |
|
|
@@ -513,11 +756,12 @@ interface ChrInstance {
|
|
|
513
756
|
sshPort: number;
|
|
514
757
|
portBase: number; // start of this instance's reserved port block
|
|
515
758
|
captureInterface: string; // "lo0" on macOS, "any" on Linux — for TZSP capture
|
|
516
|
-
|
|
759
|
+
hostGatewayIp: string; // "10.0.2.2" — the host, as seen from inside the guest
|
|
517
760
|
|
|
518
761
|
waitForBoot(timeoutMs?: number): Promise<boolean>;
|
|
519
762
|
waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
|
|
520
|
-
stop(): Promise<void>;
|
|
763
|
+
stop(): Promise<void>; // power cut: SIGTERM, then SIGKILL
|
|
764
|
+
shutdown(opts?: ShutdownOptions): Promise<ShutdownResult>; // /system/shutdown, bounded fallback
|
|
521
765
|
remove(): Promise<void>;
|
|
522
766
|
clean(): Promise<void>;
|
|
523
767
|
destroy(): Promise<void>; // stop + remove
|
|
@@ -585,7 +829,8 @@ redact before logging or attaching to bug reports.
|
|
|
585
829
|
| `arch` | `"arm64" \| "x86"` | host | |
|
|
586
830
|
| `cpu` | number | `1` | |
|
|
587
831
|
| `mem` | number | `512` (1024 cross-arch TCG) | MiB |
|
|
588
|
-
| `background` | boolean | `true` | |
|
|
832
|
+
| `background` | boolean | `true` | Where the serial console goes — not whether `start()` waits |
|
|
833
|
+
| `wait` | boolean | `true` | `false` resolves once QEMU is running and leaves readiness to the caller (`instance.waitForBoot()`); refused with provisioning or `background: false` (`INVALID_ARGUMENT`) |
|
|
589
834
|
| `bootDiskFormat` | `"raw" \| "qcow2"` | `"raw"` (auto-`qcow2` if `bootSize`) | |
|
|
590
835
|
| `bootSize` | string | — | e.g., `"512M"`, `"2G"`. Needs `qemu-img` |
|
|
591
836
|
| `extraDisks` | string[] | `[]` | Each entry is a size string |
|
|
@@ -643,7 +888,7 @@ const chr = await QuickCHR.start({
|
|
|
643
888
|
```
|
|
644
889
|
|
|
645
890
|
To **receive** UDP a guest sends, no forward is needed — bind an *unconnected*
|
|
646
|
-
host socket and have the guest send to `chr.
|
|
891
|
+
host socket and have the guest send to `chr.hostGatewayIp` (`10.0.2.2`); see
|
|
647
892
|
[`docs/networking-recipes.md`](docs/networking-recipes.md) §2 and
|
|
648
893
|
[`examples/udp-gateway/`](examples/udp-gateway/).
|
|
649
894
|
|
|
@@ -684,6 +929,24 @@ The entry also re-exports the lower-level version helpers for pre-flight checks:
|
|
|
684
929
|
`isProvisioningSupportedVersion` (validate a version against the provisioning floor
|
|
685
930
|
before calling `start()`).
|
|
686
931
|
|
|
932
|
+
### Cache helpers
|
|
933
|
+
|
|
934
|
+
`cacheKey()` and `cacheAdd()` are the library equivalents of the CLI cache
|
|
935
|
+
subcommands:
|
|
936
|
+
|
|
937
|
+
```ts
|
|
938
|
+
import { cacheAdd, cacheKey, CACHE_VERSION_UNRESOLVED } from "@tikoci/quickchr";
|
|
939
|
+
|
|
940
|
+
const { dir, version, arch } = await cacheKey({ channel: "stable", arch: "x86" });
|
|
941
|
+
if (version !== CACHE_VERSION_UNRESOLVED) {
|
|
942
|
+
await cacheAdd({ version, arch });
|
|
943
|
+
}
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
`cacheKey({ version })` validates the pin without resolving a channel.
|
|
947
|
+
`cacheAdd()` returns `{ dir, version, arch, path, cacheHit }`; it only downloads
|
|
948
|
+
and extracts the image and never creates or boots a machine.
|
|
949
|
+
|
|
687
950
|
---
|
|
688
951
|
|
|
689
952
|
## 5. Provisioning pipeline
|
|
@@ -723,10 +986,43 @@ port mappings work on any 7.x.
|
|
|
723
986
|
Disabling admin **does not** lock the REST API — RouterOS's `expired`
|
|
724
987
|
flag only gates CLI/SSH/Winbox login, not REST.
|
|
725
988
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
989
|
+
A failure in any step fails the whole first boot, and quickchr rolls it back
|
|
990
|
+
rather than leave a half-provisioned guest: a machine `start` created in that
|
|
991
|
+
call is removed (as a boot timeout removes it), and a machine created by `add`
|
|
992
|
+
has its disk reset to the factory image and `machine.json` restored to what
|
|
993
|
+
`add` recorded. Either way the retry is **the same command again**. With
|
|
994
|
+
`QUICKCHR_PRESERVE_ON_FAILURE=1` the half-provisioned machine is kept, stopped,
|
|
995
|
+
for inspection — `quickchr clean <name>` then resets it.
|
|
996
|
+
|
|
997
|
+
On the first `start` of a machine created by `add`, a provisioning option you
|
|
998
|
+
leave out keeps what `add` recorded, and an explicit negation
|
|
999
|
+
(`--no-install-all-packages`, `--no-disable-admin`, `--no-secure-login`,
|
|
1000
|
+
`--no-device-mode`) overrides it.
|
|
1001
|
+
|
|
1002
|
+
**The provisioning window closes at the machine's first boot** — not at
|
|
1003
|
+
`add`. A machine created with no provisioning options can still take them
|
|
1004
|
+
on its first `start`, which is where they are applied from `machine.json`.
|
|
1005
|
+
After that boot the guest may hold configuration quickchr did not put
|
|
1006
|
+
there, so provisioning cannot run against it: an option passed anyway is
|
|
1007
|
+
**refused**, never ignored, with `PROVISIONING_WINDOW_CLOSED` naming where
|
|
1008
|
+
the change can still be made. An option `machine.json` already records is
|
|
1009
|
+
reported as already applied rather than failing, so passing the same flags
|
|
1010
|
+
on every start is fine.
|
|
1011
|
+
|
|
1012
|
+
Routes that still work after the window has closed:
|
|
1013
|
+
|
|
1014
|
+
| change | route |
|
|
1015
|
+
|---|---|
|
|
1016
|
+
| license | `quickchr set <name> --license` |
|
|
1017
|
+
| device-mode | `quickchr set <name> --device-mode…` / `instance.setDeviceMode()` (power-cycles the VM) |
|
|
1018
|
+
| packages | `instance.installPackage()` (library; reboots the guest) |
|
|
1019
|
+
| anything else | `quickchr clean <name>` resets the disk to the factory image and reopens the window, at first-boot cost |
|
|
1020
|
+
|
|
1021
|
+
`clean()` retains provisioning *intent* — packages, device-mode, managed login — so the
|
|
1022
|
+
next `start` re-applies those on its own. It clears `user`, `disableAdmin` and
|
|
1023
|
+
`licenseLevel`, which are facts about the erased guest rather than intent, and a refused
|
|
1024
|
+
request is never persisted. **Repeat the original command after the reset** rather than
|
|
1025
|
+
a bare `quickchr start <name>`, or those options are silently left out.
|
|
730
1026
|
|
|
731
1027
|
**Always read back what we wrote.** This catches version-specific drift
|
|
732
1028
|
in REST responses and surfaces actionable errors instead of silent
|
|
@@ -810,7 +1106,7 @@ Picking among the specifiers/forwards by *goal* — full guide with recipes in
|
|
|
810
1106
|
|---|---|---|---|---|
|
|
811
1107
|
| `user` | SLiRP user-mode NAT, hostfwd port mapping | no | no | via hostfwd ports only |
|
|
812
1108
|
| `socket::<name>` | L2 socket pair between two VMs sharing the name | no | yes | no |
|
|
813
|
-
| `socket
|
|
1109
|
+
| `socket:listen:<port>` / `socket:connect:<port>` / `socket:mcast:<group>:<port>` | low-level QEMU socket netdevs | no | yes | no |
|
|
814
1110
|
| `shared` | NAT'd L3 with DHCP from the host | yes (or `socket_vmnet` rootless) | yes | yes |
|
|
815
1111
|
| `bridged:<iface>` | L2 bridged onto host iface | yes (or `socket_vmnet` rootless) | yes | yes |
|
|
816
1112
|
| `tap:<iface>` | pre-created TAP, no quickchr setup | depends | yes | yes |
|
|
@@ -821,15 +1117,64 @@ Resolution chains:
|
|
|
821
1117
|
- **`shared`** on macOS: socket_vmnet daemon → vmnet-shared (root-only QEMU). On Linux: pre-created TAP owned by the user, else root-created TAP+bridge.
|
|
822
1118
|
- **`bridged:<iface>`** on macOS: socket_vmnet bridged → vmnet-bridged (root). On Linux: TAP attached to a bridge containing the named iface.
|
|
823
1119
|
|
|
1120
|
+
> The table lists the strings the **CLI** takes. The library's equivalents are the
|
|
1121
|
+
> `NetworkSpecifier` type names, which are spelled with a dash rather than a colon:
|
|
1122
|
+
> `socket:listen:5000` on the command line is `{ type: "socket-listen", port: 5000 }`
|
|
1123
|
+
> in TypeScript.
|
|
1124
|
+
|
|
824
1125
|
### Named L2 sockets
|
|
825
1126
|
|
|
826
|
-
`quickchr networks sockets create lab-sw1`
|
|
827
|
-
|
|
828
|
-
`--add-network socket::lab-sw1`
|
|
829
|
-
|
|
1127
|
+
`quickchr networks sockets create lab-sw1` registers a link in
|
|
1128
|
+
`<dataDir>/networks/lab-sw1.json`, and two machines using
|
|
1129
|
+
`--add-network socket::lab-sw1` join it. A link carries a **transport**, fixed when
|
|
1130
|
+
the name is created and printed by `start`, `networks sockets` and `create` itself —
|
|
1131
|
+
you never have to open a file under the data dir to find out what your link is.
|
|
1132
|
+
|
|
1133
|
+
| `--mode` | Transport | Machines | Start order | Notes |
|
|
1134
|
+
|---|---|---|---|---|
|
|
1135
|
+
| `dgram` (default on macOS/Linux) | a pair of unix datagram sockets | 2 | either first | no host port, no UDP syscalls; needs QEMU 7.2+ |
|
|
1136
|
+
| `listen-connect` (default on Windows) | TCP pair on loopback | 2 | the listening end first | the first machine to *join* takes the listening end and keeps it; a connector never retries, so start that machine first |
|
|
1137
|
+
| `mcast` | UDP multicast on `230.0.0.1` | any number | any | the only N-way segment — **and the only one that fails silently** |
|
|
1138
|
+
|
|
1139
|
+
`mcast` is worth the warning it prints at create time. Three separate hazards, all with
|
|
1140
|
+
the same signature — interfaces up, addresses assigned, 100% packet loss, nothing logged:
|
|
1141
|
+
|
|
1142
|
+
- **It does not deliver on macOS.** QEMU's multicast socket omits `SO_REUSEPORT`, which
|
|
1143
|
+
macOS/BSD require before two sockets on one group both receive.
|
|
1144
|
+
- **It is refused where unconnected UDP sends are blocked** — seccomp-filtered sandboxes
|
|
1145
|
+
and some CI containers, including on Linux. The group still joins cleanly; only the
|
|
1146
|
+
send is denied.
|
|
1147
|
+
- **It is not confined to your machine.** quickchr does not pass `localaddr=`, so the
|
|
1148
|
+
group rides the host's default multicast interface rather than loopback. Another
|
|
1149
|
+
machine on your LAN using the same group joins your segment.
|
|
1150
|
+
|
|
1151
|
+
Use it when you genuinely need more than two machines on one segment, give it a group of
|
|
1152
|
+
its own, and check the link before you trust it. For two machines, `dgram` is both
|
|
1153
|
+
confined to the filesystem and able to refuse a bad configuration up front.
|
|
1154
|
+
|
|
1155
|
+
The two-machine modes hold their ends in named slots, so a machine that stops and
|
|
1156
|
+
starts again comes back on the same end. That is what makes a restart safe, and it is
|
|
1157
|
+
also why `listen-connect` has a start order at all: the roles are fixed when the two
|
|
1158
|
+
machines first join, and from then on the listening end has to be running before the
|
|
1159
|
+
connecting one starts, because QEMU's `connect=` fails silently and never retries.
|
|
1160
|
+
`dgram` has no such constraint — either end may start first, every time. A third machine is refused, with a pointer
|
|
1161
|
+
to `--mode mcast`:
|
|
1162
|
+
|
|
1163
|
+
```console
|
|
1164
|
+
$ quickchr start --name chr3 --add-network socket::lab-sw1
|
|
1165
|
+
Error [NETWORK_UNAVAILABLE]: Named socket "lab-sw1" is a dgram link and carries 2
|
|
1166
|
+
machines; chr1 and chr2 already hold both ends. Stop one of them, or create an N-way
|
|
1167
|
+
segment with 'quickchr networks sockets create <name> --mode mcast'.
|
|
1168
|
+
```
|
|
1169
|
+
|
|
1170
|
+
quickchr unregisters socket members on `stop`/`remove`/`clean`, which frees the end
|
|
1171
|
+
for another machine. `networks sockets remove` refuses a link that still has members —
|
|
1172
|
+
removing it unlinks the endpoint sockets, which would kill a running link without
|
|
1173
|
+
saying so.
|
|
830
1174
|
|
|
831
|
-
|
|
832
|
-
|
|
1175
|
+
Joining a link takes a registry lock, so `quickchr start a & quickchr start b &` is
|
|
1176
|
+
safe: without it both machines claim the same endpoint and are handed the same socket
|
|
1177
|
+
path.
|
|
833
1178
|
|
|
834
1179
|
For platform internals (vmnet quirks, TAP creation, bridged-mode
|
|
835
1180
|
restrictions, half-open SLiRP under TCG), see `docs/networking.md`.
|
|
@@ -842,6 +1187,7 @@ restrictions, half-open SLiRP under TCG), see `docs/networking.md`.
|
|
|
842
1187
|
|
|
843
1188
|
```text
|
|
844
1189
|
$XDG_DATA_HOME/quickchr/ (defaults to ~/.local/share/quickchr/)
|
|
1190
|
+
├── .allocate.lock/ held while a new machine picks its ports/MACs
|
|
845
1191
|
├── cache/ Downloaded RouterOS images
|
|
846
1192
|
│ ├── chr-7.22.1.img.zip
|
|
847
1193
|
│ ├── chr-7.22.1.img
|
|
@@ -857,7 +1203,7 @@ $XDG_DATA_HOME/quickchr/ (defaults to ~/.local/share/quickchr/)
|
|
|
857
1203
|
│ ├── qga.sock QGA (x86 only)
|
|
858
1204
|
│ ├── qemu.pid
|
|
859
1205
|
│ ├── qemu.log
|
|
860
|
-
│ ├──
|
|
1206
|
+
│ ├── .lock/ lifecycle lock + in-flight port/MAC reservation
|
|
861
1207
|
│ └── ssh/id_ed25519[.pub] SSH key (if managed user provisioned)
|
|
862
1208
|
└── networks/ Named L2 socket reservations
|
|
863
1209
|
└── <name>.json
|
|
@@ -890,11 +1236,11 @@ is unset.
|
|
|
890
1236
|
### Credentials (separate from data dir)
|
|
891
1237
|
|
|
892
1238
|
- **Per-instance CHR credentials**: file in the user config dir
|
|
893
|
-
(`~/.config/quickchr/
|
|
1239
|
+
(`~/.config/quickchr/com.quickchr.instance.json`; not the OS keychain — a
|
|
894
1240
|
Keychain dialog hangs in non-TTY contexts like tests and CI).
|
|
895
1241
|
- **MikroTik.com web account** (for license operations): tries
|
|
896
1242
|
`Bun.secrets` (OS keychain) when interactive, falls back to the same
|
|
897
|
-
config-file store otherwise.
|
|
1243
|
+
config-file store otherwise (`~/.config/quickchr/com.quickchr.mikrotik-web.json`).
|
|
898
1244
|
|
|
899
1245
|
Both honor explicit CLI flags / env vars first.
|
|
900
1246
|
|
|
@@ -926,8 +1272,10 @@ If an extra mapping reuses a built-in service name such as `winbox`, it
|
|
|
926
1272
|
pins/replaces that service's host port rather than creating a second
|
|
927
1273
|
`ports.winbox` entry.
|
|
928
1274
|
|
|
929
|
-
If port `9100` is in use, the next instance gets `9110`, etc.
|
|
930
|
-
|
|
1275
|
+
If port `9100` is in use, the next instance gets `9110`, etc. Concurrent
|
|
1276
|
+
creations get distinct blocks (see **Concurrency** under [Static methods on `QuickCHR`](#static-methods-on-quickchr)).
|
|
1277
|
+
Manual override via `--port-base`, which is never remapped: a base that
|
|
1278
|
+
overlaps another machine's block is a `PORT_CONFLICT`.
|
|
931
1279
|
|
|
932
1280
|
---
|
|
933
1281
|
|
|
@@ -967,8 +1315,12 @@ Native (HVF/KVM): factor ~1 → 120s base.
|
|
|
967
1315
|
Cross-arch TCG: factor ~15 → up to 1800s.
|
|
968
1316
|
Package install adds a 2× multiplier (extra reboot cycle).
|
|
969
1317
|
|
|
970
|
-
If boot times out,
|
|
971
|
-
|
|
1318
|
+
If boot times out, QEMU is stopped. A previously booted machine is **preserved**
|
|
1319
|
+
with its disks, credentials and configuration; retry with `quickchr start <name>`
|
|
1320
|
+
or explicitly remove it. A machine created by this `start()` is removed, and an
|
|
1321
|
+
existing never-booted machine is reset to its factory disk and pre-launch record.
|
|
1322
|
+
`QUICKCHR_PRESERVE_ON_FAILURE=1` skips removal/reset for those first-boot cases.
|
|
1323
|
+
See the [cleanup table](#auto-cleanup-on-boot-timeout) for all three cases. Use `--timeout-extra` to add a buffer if you know your host is slow, or
|
|
972
1324
|
persist a default via `quickchr settings set timeout-extra <seconds>` so
|
|
973
1325
|
every `start` picks it up without repeating the flag.
|
|
974
1326
|
|
|
@@ -985,7 +1337,8 @@ against:
|
|
|
985
1337
|
- Accidental credential leakage in commits, CI logs, CLI output.
|
|
986
1338
|
|
|
987
1339
|
We **don't** protect against same-user code reading
|
|
988
|
-
`
|
|
1340
|
+
the `~/.config/quickchr/*.json` credential files, `ps`-visible CLI args, or memory
|
|
1341
|
+
inspection.
|
|
989
1342
|
|
|
990
1343
|
### Auth resolution (`resolveAuth`)
|
|
991
1344
|
|
|
@@ -1027,13 +1380,14 @@ the 6 managed keys are all structural preferences, not secrets.
|
|
|
1027
1380
|
| `QUICKCHR_NO_PROMPT` | CLI dispatcher — forces non-interactive mode |
|
|
1028
1381
|
| `QUICKCHR_DEBUG` | progress logger — emits `[debug]` lines |
|
|
1029
1382
|
| `QUICKCHR_INTEGRATION` | `bun:test` gate for `test/integration/` |
|
|
1030
|
-
| `QUICKCHR_PRESERVE_ON_FAILURE` | `quickchr.ts` — skip `remove()` on `BOOT_TIMEOUT
|
|
1383
|
+
| `QUICKCHR_PRESERVE_ON_FAILURE` | `quickchr.ts` — skip `remove()` on `BOOT_TIMEOUT`, and the rollback of a failed provisioning run, so the machine dir survives for diagnosis |
|
|
1031
1384
|
| `QUICKCHR_SERIAL_LOG` | `qemu.ts` — adds `logfile=` to the serial chardev (writes cleartext provisioning passwords) |
|
|
1032
1385
|
| `QUICKCHR_DEEP_BOOT_DIAGNOSTICS` | `guest-snapshot.ts` — allows the guest-mutating counting-rule probe during boot-failure capture |
|
|
1033
1386
|
| `QUICKCHR_DEFAULT_CHANNEL` | `settings.ts` — env tier for the `default-channel` setting |
|
|
1034
1387
|
| `QUICKCHR_DEFAULT_ARCH` | `settings.ts` — env tier for the `default-arch` setting |
|
|
1035
1388
|
| `QUICKCHR_ACCEL` | `settings.ts` / `platform.ts` — env tier for the `accel` setting; forces `-accel` and bypasses detection |
|
|
1036
1389
|
| `QUICKCHR_CACHE_MAX_SIZE` | `settings.ts` — env tier for the `cache-max-size` setting |
|
|
1390
|
+
| `QUICKCHR_SHUTDOWN_TIMEOUT` | CLI `shutdown` grace budget in positive integer seconds; default 10, overridden by `--timeout` |
|
|
1037
1391
|
| `QUICKCHR_TIMEOUT_EXTRA` | `settings.ts` — env tier for the `timeout-extra` setting |
|
|
1038
1392
|
| `QUICKCHR_SECURE_LOGIN` | `settings.ts` — env tier for the `secure-login` setting |
|
|
1039
1393
|
| `MIKROTIK_WEB_ACCOUNT` | license-renewal account (fallback) |
|
|
@@ -1066,6 +1420,22 @@ derived Basic auth header. Do not commit or publicly log these values.
|
|
|
1066
1420
|
`quickchr exec <name> <command>` and `chr.exec(command, opts)` both
|
|
1067
1421
|
funnel through the same transport selector.
|
|
1068
1422
|
|
|
1423
|
+
> **`exec` does not validate your command.** It is a pipe: whatever you pass is what
|
|
1424
|
+
> RouterOS is asked to run, and a malformed command comes back as RouterOS's own
|
|
1425
|
+
> rejection. For anything beyond a one-liner, use
|
|
1426
|
+
> [centrs](https://github.com/tikoci/centrs), which checks a command against the
|
|
1427
|
+
> device's own command tree *before* running it and has per-path help:
|
|
1428
|
+
>
|
|
1429
|
+
> ```bash
|
|
1430
|
+
> centrs retrieve --quickchr my-chr /system/resource # read state
|
|
1431
|
+
> centrs execute --quickchr my-chr <command> # read/write command
|
|
1432
|
+
> centrs explain <command> # analyze without running
|
|
1433
|
+
> ```
|
|
1434
|
+
>
|
|
1435
|
+
> centrs resolves the machine's live connection facts through `quickchr inspect`
|
|
1436
|
+
> (see [`docs/centrs-interface.md`](./docs/centrs-interface.md)) — it never reads
|
|
1437
|
+
> `machine.json`, and neither should you.
|
|
1438
|
+
|
|
1069
1439
|
### Transports
|
|
1070
1440
|
|
|
1071
1441
|
| `--via` | Source | Notes |
|
|
@@ -1181,7 +1551,13 @@ stored instance credentials, `state.user`, the managed SSH keypair under
|
|
|
1181
1551
|
`<machineDir>/ssh/`, and `disableAdmin` — because the accounts they named
|
|
1182
1552
|
went with the disk. The machine comes back as a factory CHR: `admin` with
|
|
1183
1553
|
an empty password, which is what `rest()`, `exec()`, and `inspect` resolve
|
|
1184
|
-
to afterwards.
|
|
1554
|
+
to afterwards.
|
|
1555
|
+
|
|
1556
|
+
`clean()` performs no provisioning of its own, but it does reopen the provisioning
|
|
1557
|
+
window: the next `start` re-applies the retained intent (`packages`, `deviceMode`,
|
|
1558
|
+
`secureLogin`) as a first boot would. The credential facts above are cleared rather
|
|
1559
|
+
than retained, so an explicit `--add-user` / `--disable-admin` — and anything from a
|
|
1560
|
+
request that was refused, which is never persisted — has to be passed again.
|
|
1185
1561
|
|
|
1186
1562
|
### Snapshots
|
|
1187
1563
|
|
|
@@ -1225,13 +1601,13 @@ present, then exits `1`.
|
|
|
1225
1601
|
| `MISSING_FIRMWARE` | edk2 UEFI firmware not installed (arm64) | install `qemu-efi-aarch64` / `edk2-aarch64` |
|
|
1226
1602
|
| `MISSING_UNZIP` | image extraction needs `unzip` | install OS package |
|
|
1227
1603
|
| `PORT_CONFLICT` | no free 10-port block | stop a machine or set `--port-base` |
|
|
1228
|
-
| `BOOT_TIMEOUT` | CHR didn't respond within timeout | check `qemu.log`; under TCG, raise `--timeout-extra`;
|
|
1604
|
+
| `BOOT_TIMEOUT` | CHR didn't respond within timeout | check `qemu.log`; under TCG, raise `--timeout-extra`; previously booted machines are **preserved**; new machines are removed, existing first boots reset, unless `QUICKCHR_PRESERVE_ON_FAILURE=1` |
|
|
1229
1605
|
| `DOWNLOAD_FAILED` | image fetch failed | retry; check connectivity to MikroTik download server |
|
|
1230
1606
|
| `INVALID_VERSION` / `INVALID_ARCH` / `INVALID_NAME` / `INVALID_DISK_SIZE` | argument parse | fix the value |
|
|
1231
1607
|
| `MACHINE_EXISTS` | name already in `machines/` | choose another name or `QuickCHR.get(name)` |
|
|
1232
1608
|
| `MACHINE_NOT_FOUND` | name not in `machines/` | `quickchr list` |
|
|
1233
1609
|
| `MACHINE_RUNNING` / `MACHINE_STOPPED` | wrong lifecycle state | `start` or `stop` first |
|
|
1234
|
-
| `MACHINE_LOCKED` | another
|
|
1610
|
+
| `MACHINE_LOCKED` | another operation holds the machine (the message names it, its pid and age) | wait for it; a `foreground` holder must be quit from its console |
|
|
1235
1611
|
| `EXEC_FAILED` | RouterOS rejected the command (auth, syntax) | check creds, command syntax |
|
|
1236
1612
|
| `PROCESS_FAILED` | QEMU spawn / reboot / verify failed | check `qemu.log`, host resources |
|
|
1237
1613
|
| `SPAWN_FAILED` | exec/spawn of host binary failed | check binary exists + executable |
|
|
@@ -1239,7 +1615,9 @@ present, then exits `1`.
|
|
|
1239
1615
|
| `QGA_TIMEOUT` | QGA daemon unreachable | KVM-only; not on macOS HVF or TCG |
|
|
1240
1616
|
| `NETWORK_UNAVAILABLE` | provisioning needs `user` NIC, none present | add `--add-network user` |
|
|
1241
1617
|
| `INVALID_NETWORK` | unknown alias / unresolvable iface / dead socket daemon | `quickchr networks` |
|
|
1242
|
-
| `PROVISIONING_VERSION_UNSUPPORTED` | provisioning option on RouterOS < 7.20.8 | use `--channel long-term` or pin a `7.20.8+` version |
|
|
1618
|
+
| `PROVISIONING_VERSION_UNSUPPORTED` | provisioning option on RouterOS < 7.20.8 (including any 6.x) | use `--channel long-term` or pin a `7.20.8+` version |
|
|
1619
|
+
| `REST_UNAVAILABLE` | REST operation (`exec`, `get`, `rest()`) on a RouterOS 6 machine | use the native API — `centrs … --via native-api --no-validate` |
|
|
1620
|
+
| `INVALID_ARGUMENT` | a flag combination quickchr will not honour — e.g. `--no-wait` with provisioning | drop one of the two |
|
|
1243
1621
|
| `INSUFFICIENT_DISK_SPACE` | host data dir full | free space or move via `QUICKCHR_DATA_DIR` |
|
|
1244
1622
|
| `STATE_ERROR` | snapshot on raw boot disk | recreate with `--boot-disk-format=qcow2` |
|
|
1245
1623
|
| `INVALID_SIZE_STRING` | invalid cache/settings size string | use size syntax like `512M`, `2G`, or `1.5G` |
|
|
@@ -1248,10 +1626,44 @@ present, then exits `1`.
|
|
|
1248
1626
|
|
|
1249
1627
|
### Auto-cleanup on boot timeout
|
|
1250
1628
|
|
|
1251
|
-
If `start()` hits `BOOT_TIMEOUT`, quickchr
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1629
|
+
If `start()` hits `BOOT_TIMEOUT`, quickchr stops the failing instance and then:
|
|
1630
|
+
|
|
1631
|
+
| Machine at launch entry | Timeout cleanup | Recovery |
|
|
1632
|
+
| --- | --- | --- |
|
|
1633
|
+
| Created by this `start()` call | Remove automatically | Repeat the original command |
|
|
1634
|
+
| Existing, never booted, provisioning window open | Reset factory disk and restore the pre-launch record, window open | `quickchr start <name>` |
|
|
1635
|
+
| Existing, provisioning window already closed | Preserve disks, credentials and persistent configuration; clear PID and release socket memberships | `quickchr start <name>`, or explicit `quickchr remove <name>` |
|
|
1636
|
+
|
|
1637
|
+
`QUICKCHR_PRESERVE_ON_FAILURE=1` skips removal/reset for the first two cases and
|
|
1638
|
+
keeps the machine as it failed. Previously booted machines survive without the
|
|
1639
|
+
flag, including older records whose only boot-history marker is `lastStartedAt`.
|
|
1640
|
+
The diagnostic report is written outside the machine directory before cleanup;
|
|
1641
|
+
QEMU logs in a preserved machine also remain available. A configured guest does
|
|
1642
|
+
not need `clean` to retry: that command deliberately erases its configuration.
|
|
1643
|
+
|
|
1644
|
+
### RouterOS 6 (boot-only)
|
|
1645
|
+
|
|
1646
|
+
A best-effort target for v6→v7 migration testing (tikoci/centrs#386), CLI only —
|
|
1647
|
+
the setup wizard does not offer it.
|
|
1648
|
+
|
|
1649
|
+
```sh
|
|
1650
|
+
quickchr add --name lab6 --version 6.49.23 --arch x86
|
|
1651
|
+
quickchr start lab6
|
|
1652
|
+
centrs retrieve --quickchr lab6 /system/resource --via native-api --no-validate
|
|
1653
|
+
```
|
|
1654
|
+
|
|
1655
|
+
- **x86 only.** MikroTik publishes no arm64 CHR for 6.x; `--arch arm64` is refused
|
|
1656
|
+
before download (`INVALID_ARCH`).
|
|
1657
|
+
- **Readiness is a native-API `/login`** on guest port 8728 (two consecutive
|
|
1658
|
+
answers), since 6.x has no REST. A 6.49.23 guest is ready in ~25 s under HVF.
|
|
1659
|
+
- **No provisioning.** Every provisioning flag, and the post-boot `set` routes, are
|
|
1660
|
+
refused before download where possible (`PROVISIONING_VERSION_UNSUPPORTED`). The
|
|
1661
|
+
`secure-login` setting is a default, not a request, so it does not apply to 6.x.
|
|
1662
|
+
- **No REST.** `exec` (REST/auto transport), `get`'s live groups and
|
|
1663
|
+
`ChrInstance.rest()` fail with `REST_UNAVAILABLE` rather than time out.
|
|
1664
|
+
`inspect` reports `rest-api` unavailable and `native-api` available.
|
|
1665
|
+
- `list`, `inspect`, `stop`, `remove` and `start --no-wait` work as for 7.x
|
|
1666
|
+
(checked live on 6.49.23).
|
|
1255
1667
|
|
|
1256
1668
|
### Where to look
|
|
1257
1669
|
|