@tikoci/quickchr 0.2.0 → 0.3.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/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +182 -4
- package/src/index.ts +8 -1
- package/src/lib/auth.ts +21 -0
- package/src/lib/cache.ts +251 -0
- package/src/lib/forward-spec.ts +92 -0
- package/src/lib/guest-ports.ts +67 -0
- package/src/lib/network.ts +73 -0
- package/src/lib/packages.ts +8 -55
- package/src/lib/quickchr.ts +83 -5
- package/src/lib/scp.ts +97 -0
- package/src/lib/socket-registry.ts +23 -4
- package/src/lib/types.ts +82 -4
|
@@ -14,7 +14,10 @@ export interface SocketEntry {
|
|
|
14
14
|
mcastGroup?: string;
|
|
15
15
|
port: number;
|
|
16
16
|
createdAt: string;
|
|
17
|
+
/** Machines that currently reference this socket. */
|
|
17
18
|
members: string[];
|
|
19
|
+
/** Auto-created by `start()` (true) vs explicit user/CLI creation (false). */
|
|
20
|
+
autoCreated: boolean;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
const DEFAULT_START_PORT = 4000;
|
|
@@ -41,6 +44,19 @@ function socketPath(name: string): string {
|
|
|
41
44
|
return join(getSocketRegistryDir(), `${name}.json`);
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
/** Normalize an entry loaded from disk: defaults missing fields for backward compat. */
|
|
48
|
+
function normalizeEntry(raw: Partial<SocketEntry> & { name: string; mode: SocketEntry["mode"]; port: number; createdAt: string }): SocketEntry {
|
|
49
|
+
return {
|
|
50
|
+
name: raw.name,
|
|
51
|
+
mode: raw.mode,
|
|
52
|
+
mcastGroup: raw.mcastGroup,
|
|
53
|
+
port: raw.port,
|
|
54
|
+
createdAt: raw.createdAt,
|
|
55
|
+
members: Array.isArray(raw.members) ? [...raw.members] : [],
|
|
56
|
+
autoCreated: typeof raw.autoCreated === "boolean" ? raw.autoCreated : false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
function saveEntry(entry: SocketEntry): void {
|
|
45
61
|
writeFileSync(socketPath(entry.name), JSON.stringify(entry, null, "\t") + "\n");
|
|
46
62
|
_cache.set(entry.name, entry);
|
|
@@ -54,7 +70,7 @@ function allocatePort(existing: SocketEntry[]): number {
|
|
|
54
70
|
|
|
55
71
|
export function createNamedSocket(
|
|
56
72
|
name: string,
|
|
57
|
-
opts?: { mode?: "mcast" | "listen-connect"; port?: number; mcastGroup?: string },
|
|
73
|
+
opts?: { mode?: "mcast" | "listen-connect"; port?: number; mcastGroup?: string; autoCreated?: boolean },
|
|
58
74
|
): SocketEntry {
|
|
59
75
|
if (_cache.has(name) || existsSync(socketPath(name))) {
|
|
60
76
|
throw new QuickCHRError("STATE_ERROR", `Named socket "${name}" already exists`);
|
|
@@ -71,6 +87,7 @@ export function createNamedSocket(
|
|
|
71
87
|
mcastGroup,
|
|
72
88
|
createdAt: new Date().toISOString(),
|
|
73
89
|
members: [],
|
|
90
|
+
autoCreated: opts?.autoCreated ?? false,
|
|
74
91
|
};
|
|
75
92
|
|
|
76
93
|
saveEntry(entry);
|
|
@@ -81,7 +98,8 @@ export function getNamedSocket(name: string): SocketEntry | undefined {
|
|
|
81
98
|
const cached = _cache.get(name);
|
|
82
99
|
if (cached) return cached;
|
|
83
100
|
try {
|
|
84
|
-
const
|
|
101
|
+
const raw = JSON.parse(readFileSync(socketPath(name), "utf-8"));
|
|
102
|
+
const entry = normalizeEntry(raw);
|
|
85
103
|
_cache.set(name, entry);
|
|
86
104
|
return entry;
|
|
87
105
|
} catch {
|
|
@@ -98,7 +116,8 @@ export function listNamedSockets(): SocketEntry[] {
|
|
|
98
116
|
const name = file.replace(/\.json$/, "");
|
|
99
117
|
if (!merged.has(name)) {
|
|
100
118
|
try {
|
|
101
|
-
const
|
|
119
|
+
const raw = JSON.parse(readFileSync(join(dir, file), "utf-8"));
|
|
120
|
+
const entry = normalizeEntry(raw);
|
|
102
121
|
merged.set(name, entry);
|
|
103
122
|
_cache.set(name, entry);
|
|
104
123
|
} catch {}
|
|
@@ -132,7 +151,7 @@ export function removeSocketMember(name: string, machineName: string): void {
|
|
|
132
151
|
const entry = getNamedSocket(name);
|
|
133
152
|
if (!entry) return;
|
|
134
153
|
entry.members = entry.members.filter((m) => m !== machineName);
|
|
135
|
-
if (entry.members.length === 0) {
|
|
154
|
+
if (entry.members.length === 0 && entry.autoCreated) {
|
|
136
155
|
removeNamedSocket(name);
|
|
137
156
|
} else {
|
|
138
157
|
saveEntry(entry);
|
package/src/lib/types.ts
CHANGED
|
@@ -195,12 +195,17 @@ export interface MachineState extends MachineConfig {
|
|
|
195
195
|
* requires SCP upload + reboot cycle, typically 60–120s extra on native acceleration).
|
|
196
196
|
*/
|
|
197
197
|
export interface StartOptions {
|
|
198
|
-
/** RouterOS version to use (e.g. "7.22.1"). Mutually exclusive with `channel`.
|
|
198
|
+
/** RouterOS version to use (e.g. "7.22.1"). Mutually exclusive with `channel`.
|
|
199
|
+
* As a convenience, a channel name (e.g. "long-term") is also accepted here and
|
|
200
|
+
* resolves the same as `channel: "long-term"` — but this emits a warning, since
|
|
201
|
+
* `channel` is the self-documenting field for that case. */
|
|
199
202
|
version?: string;
|
|
200
203
|
/** Release channel to resolve latest version from. Default: "stable". */
|
|
201
204
|
channel?: Channel;
|
|
202
|
-
/** Guest architecture. Default: matches host (arm64 on Apple Silicon, x86 on Intel/AMD).
|
|
203
|
-
|
|
205
|
+
/** Guest architecture. Default: matches host (arm64 on Apple Silicon, x86 on Intel/AMD).
|
|
206
|
+
* Pass `"auto"` as an explicit synonym for the default — useful when an agent or
|
|
207
|
+
* caller wants to state the intent rather than omit the field. */
|
|
208
|
+
arch?: Arch | "auto";
|
|
204
209
|
/** Instance name. Auto-generated from version+arch if omitted. Must not start with "-". */
|
|
205
210
|
name?: string;
|
|
206
211
|
/** Number of virtual CPUs. Default: 1. */
|
|
@@ -226,6 +231,11 @@ export interface StartOptions {
|
|
|
226
231
|
* Enabling the managed login path is quickchr provisioning, so it is validated/tested
|
|
227
232
|
* on RouterOS 7.20.8+ only. */
|
|
228
233
|
secureLogin?: boolean;
|
|
234
|
+
/** Convenience alias: `noAuth: true` is equivalent to `secureLogin: false` —
|
|
235
|
+
* skip the managed `quickchr` user provisioning, leave admin password-less.
|
|
236
|
+
* Self-documenting alternative for callers who find `secureLogin: false` cryptic.
|
|
237
|
+
* When both are set, an explicit `secureLogin` value wins. */
|
|
238
|
+
noAuth?: boolean;
|
|
229
239
|
/** Starting port number for this instance's port block. Auto-allocated if omitted. */
|
|
230
240
|
portBase?: number;
|
|
231
241
|
/** Services to exclude from port mappings (e.g. ["winbox", "api-ssl"]). */
|
|
@@ -304,12 +314,51 @@ export interface ChrInstance {
|
|
|
304
314
|
ports: ChrPorts;
|
|
305
315
|
restUrl: string;
|
|
306
316
|
sshPort: number;
|
|
317
|
+
/** Port block base for this instance. Useful for picking L2 socket ports
|
|
318
|
+
* that won't collide with this instance's management ports.
|
|
319
|
+
* Each block occupies `PORTS_PER_BLOCK` (10) consecutive ports starting here.
|
|
320
|
+
* @example
|
|
321
|
+
* // Pick a socket port outside any running instance's block:
|
|
322
|
+
* const socketPort = Math.max(...instances.map(i => i.portBase)) + PORTS_PER_BLOCK;
|
|
323
|
+
*/
|
|
324
|
+
portBase: number;
|
|
325
|
+
/** Platform-appropriate network interface for capturing TZSP traffic from this instance.
|
|
326
|
+
* `"lo0"` on macOS — QEMU user-mode (slirp) routes guest UDP through the host loopback.
|
|
327
|
+
* `"any"` on Linux — TZSP arrives on whichever interface the host routes it to.
|
|
328
|
+
* Pass directly to tshark: `tshark -i ${instance.captureInterface} -f "udp port 37008"`
|
|
329
|
+
* Only meaningful when the instance has a user-mode (`"user"`) network interface. */
|
|
330
|
+
captureInterface: string;
|
|
331
|
+
/** QEMU user-mode networking gateway IP — the host's address as seen from inside the VM.
|
|
332
|
+
* Use this as the RouterOS `/tool/sniffer` streaming-server target so TZSP traffic
|
|
333
|
+
* reaches the host. Always `"10.0.2.2"` for QEMU slirp user-mode networking.
|
|
334
|
+
* @example
|
|
335
|
+
* await instance.exec(`/tool/sniffer/set streaming-server=${instance.tzspGatewayIp}:37008`);
|
|
336
|
+
*/
|
|
337
|
+
tzspGatewayIp: string;
|
|
307
338
|
|
|
308
339
|
/** Check or wait for REST API readiness.
|
|
309
340
|
* After `QuickCHR.start()` with provisioning options (installAllPackages, license, etc.),
|
|
310
341
|
* the returned instance is already REST-reachable — this returns immediately.
|
|
311
342
|
* Call this only when starting without provisioning, or as a safety check after a manual stop/restart. */
|
|
312
343
|
waitForBoot(timeoutMs?: number): Promise<boolean>;
|
|
344
|
+
/** Poll a condition until it returns true or the timeout elapses.
|
|
345
|
+
* Retries every 2 s, swallowing errors from the condition function.
|
|
346
|
+
* Returns `true` when the condition passes, `false` on timeout.
|
|
347
|
+
*
|
|
348
|
+
* Useful for waiting on RouterOS state that isn't reflected in the boot readiness
|
|
349
|
+
* check — e.g. waiting for a package to become active, a daemon to enable, or a
|
|
350
|
+
* background script to complete.
|
|
351
|
+
*
|
|
352
|
+
* @param condition Async predicate: return `true` when the desired state is reached.
|
|
353
|
+
* @param timeoutMs Maximum wait in milliseconds (default: 30 000).
|
|
354
|
+
* @example
|
|
355
|
+
* // Wait for the Dude daemon to report enabled=yes
|
|
356
|
+
* const ready = await instance.waitFor(async () => {
|
|
357
|
+
* const r = await instance.exec("/dude/print");
|
|
358
|
+
* return r.output.includes("enabled: yes");
|
|
359
|
+
* }, 30_000);
|
|
360
|
+
*/
|
|
361
|
+
waitFor(condition: () => Promise<boolean>, timeoutMs?: number): Promise<boolean>;
|
|
313
362
|
stop(): Promise<void>;
|
|
314
363
|
remove(): Promise<void>;
|
|
315
364
|
clean(): Promise<void>;
|
|
@@ -329,7 +378,19 @@ export interface ChrInstance {
|
|
|
329
378
|
qga(command: QgaCommand, args?: object): Promise<unknown>;
|
|
330
379
|
|
|
331
380
|
rest(path: string, opts?: RequestInit): Promise<unknown>;
|
|
332
|
-
/** Run a RouterOS CLI command against the instance.
|
|
381
|
+
/** Run a RouterOS CLI command against the instance.
|
|
382
|
+
*
|
|
383
|
+
* **Single command per call.** RouterOS `/rest/execute` runs the input as one
|
|
384
|
+
* script statement; a multi-line `command` string with `\n`-separated commands
|
|
385
|
+
* may execute only the first line and silently ignore the rest depending on
|
|
386
|
+
* RouterOS version. To run several commands, call `exec()` multiple times, or
|
|
387
|
+
* wrap them in an explicit RouterOS script: `:do { /cmd1; /cmd2 }`.
|
|
388
|
+
*
|
|
389
|
+
* **Soft errors.** Some RouterOS commands return HTTP 200 with an error
|
|
390
|
+
* string in `output` (e.g. `/dude/agent/add` may resolve OK with
|
|
391
|
+
* `"doAdd Agent not implemented"`). `exec()` does not parse output for these
|
|
392
|
+
* patterns — inspect `result.output` if you need to assert success.
|
|
393
|
+
*/
|
|
333
394
|
exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
334
395
|
/** Apply or renew a CHR trial license.
|
|
335
396
|
* quickchr validates this provisioning flow on RouterOS 7.20.8+ only. */
|
|
@@ -343,6 +404,22 @@ export interface ChrInstance {
|
|
|
343
404
|
* @param logger Optional progress logger for status/debug output. */
|
|
344
405
|
setDeviceMode(options: DeviceModeOptions, logger?: ProgressLogger): Promise<void>;
|
|
345
406
|
|
|
407
|
+
/** Copy a local file to the CHR via SCP.
|
|
408
|
+
* Machine must be running. `remotePath` defaults to `/<basename>` (RouterOS flash root).
|
|
409
|
+
* Uses the resolved instance credentials.
|
|
410
|
+
* @example
|
|
411
|
+
* await instance.upload("./dude.db", "/dude/dude.db");
|
|
412
|
+
* await instance.upload("./config.rsc"); // → /config.rsc
|
|
413
|
+
*/
|
|
414
|
+
upload(localPath: string, remotePath?: string): Promise<void>;
|
|
415
|
+
|
|
416
|
+
/** Copy a file from the CHR to the local filesystem via SCP.
|
|
417
|
+
* Machine must be running. Uses the resolved instance credentials.
|
|
418
|
+
* @example
|
|
419
|
+
* await instance.download("/dude/dude.db", "./dude-snapshot.db");
|
|
420
|
+
*/
|
|
421
|
+
download(remotePath: string, localPath: string): Promise<void>;
|
|
422
|
+
|
|
346
423
|
/** List extra packages available for this instance's version and arch.
|
|
347
424
|
* Downloads and caches the all_packages ZIP on the first call. */
|
|
348
425
|
availablePackages(): Promise<string[]>;
|
|
@@ -460,6 +537,7 @@ export type ErrorCode =
|
|
|
460
537
|
| "SPAWN_FAILED"
|
|
461
538
|
| "NETWORK_UNAVAILABLE"
|
|
462
539
|
| "INVALID_NETWORK"
|
|
540
|
+
| "INVALID_FORWARD_SPEC"
|
|
463
541
|
| "PROVISIONING_VERSION_UNSUPPORTED"
|
|
464
542
|
| "INSUFFICIENT_DISK_SPACE"
|
|
465
543
|
| "STATE_ERROR";
|