@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/state.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { join, } from "node:path";
|
|
|
7
7
|
import type { MachineState, } from "./types.ts";
|
|
8
8
|
import { QuickCHRError } from "./types.ts";
|
|
9
9
|
import { networkModeToConfigs } from "./network.ts";
|
|
10
|
+
import { assertPathSafeName, isPathSafeName } from "./names.ts";
|
|
10
11
|
|
|
11
12
|
/** Get the quickchr data directory root. Override with QUICKCHR_DATA_DIR env var. */
|
|
12
13
|
export function getDataDir(): string {
|
|
@@ -91,12 +92,72 @@ export function saveMachine(state: MachineState): void {
|
|
|
91
92
|
writeFileSync(machineJsonPath(state.name), JSON.stringify(state, null, "\t") + "\n");
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
/**
|
|
95
|
+
/** The fields every caller dereferences without guarding, with the `typeof` each must
|
|
96
|
+
* answer. Deliberately not a schema: it is the set that turns a bad file into a crash
|
|
97
|
+
* somewhere else, which is the thing being prevented. `cpu`/`mem` are absent because a
|
|
98
|
+
* missing one prints oddly rather than throwing, and a narrow check cannot wrongly
|
|
99
|
+
* condemn a machine that works today. `networks` is absent because `loadMachine()`
|
|
100
|
+
* migrates it just below.
|
|
101
|
+
*
|
|
102
|
+
* Every field listed here has been required on `MachineState` since the commit that
|
|
103
|
+
* introduced it (5cfe052), so no `machine.json` any version of quickchr wrote can be
|
|
104
|
+
* condemned by this check — which is the thing that would make it worse than the bug
|
|
105
|
+
* it fixes. */
|
|
106
|
+
const REQUIRED_STATE_FIELDS: ReadonlyArray<[keyof MachineState, "string" | "number" | "object"]> = [
|
|
107
|
+
["name", "string"],
|
|
108
|
+
["version", "string"],
|
|
109
|
+
["arch", "string"],
|
|
110
|
+
["status", "string"],
|
|
111
|
+
["machineDir", "string"],
|
|
112
|
+
["portBase", "number"],
|
|
113
|
+
["ports", "object"],
|
|
114
|
+
["packages", "object"],
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
/** Why `parsed` is not usable as a `MachineState`, or `undefined` when it is.
|
|
118
|
+
*
|
|
119
|
+
* Parsing successfully is not the same as being state. `{"name":"lab"}` parses, and
|
|
120
|
+
* `JSON.parse` is happy with `null` and `[]` too; casting any of them to `MachineState`
|
|
121
|
+
* only moves the failure — `{"name":"lab"}` reached `formatPorts(m.ports)` and aborted
|
|
122
|
+
* `quickchr list` exactly as an unparseable file used to (#165). */
|
|
123
|
+
function stateShapeProblem(parsed: unknown): string | undefined {
|
|
124
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
125
|
+
return "not a JSON object";
|
|
126
|
+
}
|
|
127
|
+
const record = parsed as Record<string, unknown>;
|
|
128
|
+
const bad = REQUIRED_STATE_FIELDS
|
|
129
|
+
.filter(([key, kind]) => typeof record[key] !== kind || record[key] === null)
|
|
130
|
+
.map(([key]) => key);
|
|
131
|
+
return bad.length > 0 ? `missing or invalid: ${bad.join(", ")}` : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Load machine state from disk. Returns undefined if not found.
|
|
135
|
+
*
|
|
136
|
+
* Throws `STATE_ERROR` when the file is there but unusable. A targeted lookup by name
|
|
137
|
+
* has to fail: the caller named *this* machine, and answering `undefined` would report
|
|
138
|
+
* a machine that exists on disk as one that never did. `loadAllMachines()` is the
|
|
139
|
+
* enumeration and takes the other policy — see `tryLoadMachine()` (#165). */
|
|
95
140
|
export function loadMachine(name: string): MachineState | undefined {
|
|
96
141
|
const path = machineJsonPath(name);
|
|
97
142
|
if (!existsSync(path)) return undefined;
|
|
98
|
-
|
|
99
|
-
|
|
143
|
+
let state: MachineState;
|
|
144
|
+
try {
|
|
145
|
+
state = JSON.parse(readFileSync(path, "utf-8")) as MachineState;
|
|
146
|
+
} catch (e) {
|
|
147
|
+
throw new QuickCHRError(
|
|
148
|
+
"STATE_ERROR",
|
|
149
|
+
`Machine "${name}" has an unreadable machine.json (${e instanceof Error ? e.message : String(e)})`,
|
|
150
|
+
`Inspect ${path}, or clear the machine with 'quickchr remove ${name}'`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const shapeProblem = stateShapeProblem(state);
|
|
154
|
+
if (shapeProblem) {
|
|
155
|
+
throw new QuickCHRError(
|
|
156
|
+
"STATE_ERROR",
|
|
157
|
+
`Machine "${name}" has an unreadable machine.json (${shapeProblem})`,
|
|
158
|
+
`Inspect ${path}, or clear the machine with 'quickchr remove ${name}'`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
100
161
|
// Migrate legacy `network` field → `networks` array
|
|
101
162
|
if (!state.networks && (state as unknown as Record<string, unknown>).network) {
|
|
102
163
|
const legacy = (state as unknown as Record<string, unknown>).network as MachineState["networks"][0]["specifier"] | "user" | "vmnet-shared" | { type: "vmnet-bridge"; iface: string };
|
|
@@ -107,7 +168,33 @@ export function loadMachine(name: string): MachineState | undefined {
|
|
|
107
168
|
return state;
|
|
108
169
|
}
|
|
109
170
|
|
|
110
|
-
/**
|
|
171
|
+
/** The three things a machine directory can turn out to be.
|
|
172
|
+
*
|
|
173
|
+
* `loadMachine()` collapses `missing` and `unreadable` into "undefined or throw", which
|
|
174
|
+
* is the right contract for a lookup and the wrong one for an enumeration. This keeps
|
|
175
|
+
* them apart so each caller can pick (#165). Internal for now: promoting it to public
|
|
176
|
+
* API is a rename if #58 lands. */
|
|
177
|
+
type MachineLoad =
|
|
178
|
+
| { status: "ok"; state: MachineState }
|
|
179
|
+
| { status: "missing" }
|
|
180
|
+
| { status: "unreadable"; error: string };
|
|
181
|
+
|
|
182
|
+
/** `loadMachine()` without the throw. */
|
|
183
|
+
function tryLoadMachine(name: string): MachineLoad {
|
|
184
|
+
try {
|
|
185
|
+
const state = loadMachine(name);
|
|
186
|
+
return state ? { status: "ok", state } : { status: "missing" };
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return { status: "unreadable", error: e instanceof Error ? e.message : String(e) };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Load all machine states, skipping directories with no usable `machine.json`.
|
|
193
|
+
*
|
|
194
|
+
* An enumeration is best-effort across many: one corrupt file used to abort the whole
|
|
195
|
+
* listing, which loses the state of every healthy machine as well. Nothing is hidden by
|
|
196
|
+
* the skip — `listUnreadableMachines()` names what was left out, and `quickchr list`
|
|
197
|
+
* shows it as a row (#165). */
|
|
111
198
|
export function loadAllMachines(): MachineState[] {
|
|
112
199
|
const dir = getMachinesDir();
|
|
113
200
|
if (!existsSync(dir)) return [];
|
|
@@ -117,13 +204,27 @@ export function loadAllMachines(): MachineState[] {
|
|
|
117
204
|
|
|
118
205
|
for (const entry of entries) {
|
|
119
206
|
if (!entry.isDirectory()) continue;
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
207
|
+
const loaded = tryLoadMachine(entry.name);
|
|
208
|
+
if (loaded.status === "ok") machines.push(loaded.state);
|
|
122
209
|
}
|
|
123
210
|
|
|
124
211
|
return machines;
|
|
125
212
|
}
|
|
126
213
|
|
|
214
|
+
/** Every machine directory whose `machine.json` exists but cannot be read.
|
|
215
|
+
*
|
|
216
|
+
* Distinct from `listOrphanMachineDirs()`, which also counts a directory with no
|
|
217
|
+
* `machine.json` at all — a create that never finished. This one is a machine that did
|
|
218
|
+
* exist and whose state went bad, so it still holds a disk image worth naming. */
|
|
219
|
+
export function listUnreadableMachines(): Array<{ name: string; error: string }> {
|
|
220
|
+
const out: Array<{ name: string; error: string }> = [];
|
|
221
|
+
for (const name of listMachineNames()) {
|
|
222
|
+
const loaded = tryLoadMachine(name);
|
|
223
|
+
if (loaded.status === "unreadable") out.push({ name, error: loaded.error });
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
127
228
|
/** Get all existing machine names. */
|
|
128
229
|
export function listMachineNames(): string[] {
|
|
129
230
|
const dir = getMachinesDir();
|
|
@@ -133,13 +234,53 @@ export function listMachineNames(): string[] {
|
|
|
133
234
|
.map((e) => e.name);
|
|
134
235
|
}
|
|
135
236
|
|
|
237
|
+
/** True when `name` has a machine directory but no readable `machine.json` — a create
|
|
238
|
+
* that did not finish. `list` cannot show it and `start` cannot boot it, but
|
|
239
|
+
* `listMachineNames()` (directory-based) still counts it, so it blocks re-add (#155). */
|
|
240
|
+
export function isOrphanMachineDir(name: string): boolean {
|
|
241
|
+
// A traversal name resolves to a directory that exists and holds no machine.json —
|
|
242
|
+
// the data dir itself, for ".." — so the path check has to come first or the
|
|
243
|
+
// predicate answers "orphan" about something that is not a machine directory.
|
|
244
|
+
if (!isPathSafeName(name)) return false;
|
|
245
|
+
const dir = getMachineDir(name);
|
|
246
|
+
if (!existsSync(dir)) return false;
|
|
247
|
+
return !isReadableMachine(name);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Every machine directory with no readable `machine.json`. */
|
|
251
|
+
export function listOrphanMachineDirs(): string[] {
|
|
252
|
+
return listMachineNames().filter((name) => !isReadableMachine(name));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A truncated or corrupt `machine.json` is as unusable as a missing one, and both make
|
|
256
|
+
* the directory an orphan. */
|
|
257
|
+
function isReadableMachine(name: string): boolean {
|
|
258
|
+
return tryLoadMachine(name).status === "ok";
|
|
259
|
+
}
|
|
260
|
+
|
|
136
261
|
/** Get all port bases currently in use by existing machines. */
|
|
137
262
|
export function getUsedPortBases(): number[] {
|
|
138
263
|
return loadAllMachines().map((m) => m.portBase);
|
|
139
264
|
}
|
|
140
265
|
|
|
266
|
+
/** Every NIC MAC currently assigned across all machines.
|
|
267
|
+
* Passed to `assignMacs()` so a new machine cannot be handed an address another
|
|
268
|
+
* machine already presents on a shared L2 segment (#154). */
|
|
269
|
+
export function getUsedMacs(): Set<string> {
|
|
270
|
+
const macs = new Set<string>();
|
|
271
|
+
for (const m of loadAllMachines()) {
|
|
272
|
+
for (const n of m.networks ?? []) {
|
|
273
|
+
if (n.mac) macs.add(n.mac);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return macs;
|
|
277
|
+
}
|
|
278
|
+
|
|
141
279
|
/** Delete a machine and all its files. */
|
|
142
280
|
export function removeMachine(name: string): void {
|
|
281
|
+
// This is an `rmSync(..., { recursive: true })`; the name must never be able to
|
|
282
|
+
// point outside machines/<name>/.
|
|
283
|
+
assertPathSafeName(name, "machine");
|
|
143
284
|
const dir = getMachineDir(name);
|
|
144
285
|
if (!existsSync(dir)) {
|
|
145
286
|
throw new QuickCHRError("MACHINE_NOT_FOUND", `Machine "${name}" not found`);
|
package/src/lib/types.ts
CHANGED
|
@@ -55,13 +55,14 @@ export type NetworkMode =
|
|
|
55
55
|
* - **Host needs to see guest L2 frames** (MNDP, MAC-Telnet, raw Ethernet,
|
|
56
56
|
* broadcasts) → `{type:"socket-connect", port}` against a host TCP server
|
|
57
57
|
* (rootless, cross-platform; recipe in `docs/mndp.md`).
|
|
58
|
-
* - **L2 link between two VMs** → `{type:"socket", name}
|
|
59
|
-
* `
|
|
58
|
+
* - **L2 link between two VMs** → `{type:"socket", name}`, a named link whose
|
|
59
|
+
* transport (`dgram`, `listen-connect`, `mcast`) is fixed when the name is created;
|
|
60
|
+
* or `socket-listen`/`socket-connect`/`socket-mcast` for a raw netdev.
|
|
60
61
|
* - **Real LAN / DHCP-from-host presence** → `"shared"` or `{type:"bridged", iface}`
|
|
61
62
|
* (rootless via socket_vmnet on macOS / pre-created TAP on Linux).
|
|
62
63
|
*
|
|
63
64
|
* Note: receiving guest-*originated* UDP (e.g. a guest replying to `10.0.2.2`)
|
|
64
|
-
* needs no extra NIC and no forward — see {@link ChrInstance.
|
|
65
|
+
* needs no extra NIC and no forward — see {@link ChrInstance.hostGatewayIp}.
|
|
65
66
|
*/
|
|
66
67
|
export type NetworkSpecifier =
|
|
67
68
|
/** SLIRP user-mode NAT + `hostfwd` port mapping. Management default; the only
|
|
@@ -106,6 +107,12 @@ export interface NetworkConfig {
|
|
|
106
107
|
specifier: NetworkSpecifier;
|
|
107
108
|
/** QEMU netdev id (net0, net1, ...). */
|
|
108
109
|
id: string;
|
|
110
|
+
/** Locally-administered MAC, assigned at creation and persisted so it survives
|
|
111
|
+
* restart. Without it QEMU hands every guest the same default sequence and two
|
|
112
|
+
* machines on one L2 segment collide — see `deriveMac` in `network.ts` (#154).
|
|
113
|
+
* Never reassigned: changing it on a booted machine orphans RouterOS's interface
|
|
114
|
+
* identity. Absent on machines created before #154, which keep QEMU's defaults. */
|
|
115
|
+
mac?: string;
|
|
109
116
|
/** Resolved QEMU args (populated at launch time, not persisted). */
|
|
110
117
|
resolved?: ResolvedNetwork;
|
|
111
118
|
}
|
|
@@ -170,6 +177,11 @@ export const SERVICE_NAMES = Object.keys(SERVICE_PORTS) as ServiceName[];
|
|
|
170
177
|
export const PORTS_PER_BLOCK = 10;
|
|
171
178
|
export const DEFAULT_PORT_BASE = 9100;
|
|
172
179
|
|
|
180
|
+
/** QEMU user-mode (SLIRP) gateway — the host's address as seen from inside a guest.
|
|
181
|
+
* Fixed by QEMU's built-in 10.0.2.0/24 user network; surfaced on a running instance
|
|
182
|
+
* as {@link ChrInstance.hostGatewayIp}. */
|
|
183
|
+
export const HOST_GATEWAY_IP = "10.0.2.2";
|
|
184
|
+
|
|
173
185
|
/**
|
|
174
186
|
* TCP endpoint for QEMU IPC channels on Windows.
|
|
175
187
|
* Used instead of named pipes when QEMU's Winsock bind() can't handle \\.\pipe\ paths.
|
|
@@ -341,7 +353,7 @@ export interface StartOptions {
|
|
|
341
353
|
* {@link lookupGuestPort} resolve guest port + proto for common service names.
|
|
342
354
|
*
|
|
343
355
|
* For *receiving* guest-originated UDP (no forward needed), see
|
|
344
|
-
* {@link ChrInstance.
|
|
356
|
+
* {@link ChrInstance.hostGatewayIp} and `docs/networking-recipes.md`.
|
|
345
357
|
* @example
|
|
346
358
|
* extraPorts: [{ name: "snmp", host: 9161, guest: 161, proto: "udp" }]
|
|
347
359
|
* @example
|
|
@@ -591,8 +603,16 @@ export interface ChrInstance {
|
|
|
591
603
|
* `docs/networking-recipes.md`, `examples/udp-gateway/`.
|
|
592
604
|
* @example
|
|
593
605
|
* // RouterOS TZSP sniffer streaming to the host:
|
|
594
|
-
* await instance.exec(`/tool/sniffer/set streaming-server=${instance.
|
|
606
|
+
* await instance.exec(`/tool/sniffer/set streaming-server=${instance.hostGatewayIp}:37008`);
|
|
595
607
|
*/
|
|
608
|
+
hostGatewayIp: string;
|
|
609
|
+
/** @deprecated Renamed to {@link ChrInstance.hostGatewayIp} — the value was never
|
|
610
|
+
* TZSP-specific, it is the host as seen from inside the guest and carries any
|
|
611
|
+
* guest→host UDP (remote syslog, NetFlow, a plain socket). The old name led
|
|
612
|
+
* readers to assume a sniffer-only primitive.
|
|
613
|
+
*
|
|
614
|
+
* Still populated with the identical value, and will be removed no earlier than
|
|
615
|
+
* the next minor. Migration is a rename at the call site — nothing else changes. */
|
|
596
616
|
tzspGatewayIp: string;
|
|
597
617
|
|
|
598
618
|
/** Check or wait for REST API readiness.
|
|
@@ -805,6 +825,10 @@ export type ErrorCode =
|
|
|
805
825
|
| "INVALID_VERSION"
|
|
806
826
|
| "INVALID_ARCH"
|
|
807
827
|
| "INVALID_NAME"
|
|
828
|
+
// A flag or argument the CLI cannot honour as written — a removed flag name, or a
|
|
829
|
+
// value it will not accept. Distinct from INVALID_SETTING_VALUE, which is about a
|
|
830
|
+
// stored setting rather than what was typed.
|
|
831
|
+
| "INVALID_ARGUMENT"
|
|
808
832
|
| "INVALID_DISK_SIZE"
|
|
809
833
|
| "MACHINE_LOCKED"
|
|
810
834
|
| "EXEC_FAILED"
|