@tikoci/quickchr 0.4.2 → 0.4.4

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/src/lib/types.ts CHANGED
@@ -25,17 +25,44 @@ export type NetworkMode =
25
25
  * Network specifier — expresses user intent for a network interface.
26
26
  * Generic specifiers (user, socket, shared, bridged) are cross-platform;
27
27
  * explicit specifiers (vmnet-shared, vmnet-bridged, tap) bypass platform resolution.
28
+ *
29
+ * Pick by traffic shape (full guide: `docs/networking-recipes.md`):
30
+ * - **Management / host→guest TCP+UDP services** → `"user"` (SLIRP + `hostfwd`).
31
+ * Keep `"user"` first (ether1) so RouterOS' DHCP client gets `10.0.2.15`.
32
+ * - **Host needs to see guest L2 frames** (MNDP, MAC-Telnet, raw Ethernet,
33
+ * broadcasts) → `{type:"socket-connect", port}` against a host TCP server
34
+ * (rootless, cross-platform; recipe in `docs/mndp.md`).
35
+ * - **L2 link between two VMs** → `{type:"socket", name}` (named pair) or
36
+ * `socket-listen`/`socket-connect`/`socket-mcast` (low-level).
37
+ * - **Real LAN / DHCP-from-host presence** → `"shared"` or `{type:"bridged", iface}`
38
+ * (rootless via socket_vmnet on macOS / pre-created TAP on Linux).
39
+ *
40
+ * Note: receiving guest-*originated* UDP (e.g. a guest replying to `10.0.2.2`)
41
+ * needs no extra NIC and no forward — see {@link ChrInstance.tzspGatewayIp}.
28
42
  */
29
43
  export type NetworkSpecifier =
44
+ /** SLIRP user-mode NAT + `hostfwd` port mapping. Management default; the only
45
+ * NIC that makes `extraPorts`/`--forward` reachable. Terminates Layer 2. */
30
46
  | "user"
47
+ /** L2 socket pair between two VMs sharing a registered `name` (`quickchr networks sockets`). */
31
48
  | { type: "socket"; name: string }
49
+ /** Low-level QEMU `socket` netdev that binds and listens (`0.0.0.0:port`). */
32
50
  | { type: "socket-listen"; port: number }
51
+ /** Low-level QEMU `socket` netdev that connects to `127.0.0.1:port`. The
52
+ * rootless, cross-platform way for a host process to receive guest L2 frames. */
33
53
  | { type: "socket-connect"; port: number }
54
+ /** Multi-VM L2 segment over UDP multicast. Works on Linux/CI; **broken on macOS**
55
+ * (QEMU sets only `SO_REUSEADDR`). */
34
56
  | { type: "socket-mcast"; group: string; port: number }
57
+ /** NAT'd L3 with DHCP from the host; resolves to socket_vmnet/vmnet (macOS) or TAP (Linux). */
35
58
  | "shared"
59
+ /** L2 bridged onto a host interface; resolves like `"shared"` but bridged. */
36
60
  | { type: "bridged"; iface: string }
61
+ /** Explicit macOS vmnet-shared (bypasses resolution; runs QEMU as root). */
37
62
  | "vmnet-shared"
63
+ /** Explicit macOS vmnet-bridged (bypasses resolution; runs QEMU as root). */
38
64
  | { type: "vmnet-bridged"; iface: string }
65
+ /** Pre-created user-owned TAP on Linux (no quickchr setup). */
39
66
  | { type: "tap"; ifname: string };
40
67
 
41
68
  /** Result of resolving a NetworkSpecifier on a specific platform. */
@@ -82,10 +109,24 @@ export interface SocketVmnetInfo {
82
109
  bridgedSockets: Record<string, string>;
83
110
  }
84
111
 
112
+ /** A single host→guest port forward, realized as one QEMU SLIRP `hostfwd`
113
+ * directive (`hostfwd=<proto>::<host>-:<guest>`).
114
+ *
115
+ * This is the programmatic equivalent of one CLI `--forward` spec. Build them
116
+ * by hand, or parse spec strings with {@link parseForwardSpec} (single port) /
117
+ * {@link expandForwardSpec} (port ranges). Forwards only reach the guest over a
118
+ * user-mode (`"user"`) NIC — see the networking recipes guide
119
+ * (`docs/networking-recipes.md`) for which mechanism fits which traffic shape. */
85
120
  export interface PortMapping {
121
+ /** Label for this forward; also the key in `MachineState.ports` and what
122
+ * `quickchr list` shows. Must be unique within a machine. */
86
123
  name: string;
124
+ /** Host-side port. `0` (or omitted) auto-allocates from the instance's port block. */
87
125
  host: number;
126
+ /** Guest-side (RouterOS) port the forward targets. */
88
127
  guest: number;
128
+ /** Transport — carried verbatim into the `hostfwd` directive. `udp` is fully
129
+ * supported (e.g. SNMP, syslog, bandwidth-test data ports). */
89
130
  proto: "tcp" | "udp";
90
131
  }
91
132
 
@@ -174,6 +215,26 @@ export interface MachineConfig {
174
215
  bootDiskFormat?: BootDiskFormat;
175
216
  }
176
217
 
218
+ /**
219
+ * Facts about the SSH keypair quickchr generated and installed for its managed
220
+ * user during provisioning. Persisted on {@link MachineState} so consumers (the
221
+ * #71 descriptor contract) can advertise SSH private-key batch auth as usable —
222
+ * but only when `batchVerified` is true. See `test/lab/ssh-keys/REPORT.md`.
223
+ */
224
+ export interface ManagedSshKey {
225
+ /** Absolute path to the private key on the host (public key is `${path}.pub`). */
226
+ privateKeyPath: string;
227
+ /** Key algorithm. Currently always `ed25519` — grounded across quickchr's
228
+ * provisioning floor and current stable in REPORT.md (issue #74). */
229
+ algorithm: string;
230
+ /** True only when a host-OpenSSH `BatchMode=yes` / `PasswordAuthentication=no`
231
+ * login with this key actually succeeded — not merely that the key appears in
232
+ * RouterOS's `/user/ssh-keys` listing. This is the signal #71 gates on. */
233
+ batchVerified: boolean;
234
+ /** ISO timestamp of the successful batch verification, when one happened. */
235
+ verifiedAt?: string;
236
+ }
237
+
177
238
  export interface MachineState extends MachineConfig {
178
239
  createdAt: string;
179
240
  lastStartedAt?: string;
@@ -181,6 +242,12 @@ export interface MachineState extends MachineConfig {
181
242
  pid?: number;
182
243
  /** Path to the machine directory. */
183
244
  machineDir: string;
245
+ /** Accelerator resolved for the most recent boot ("kvm" | "hvf" | "tcg"). */
246
+ lastAccel?: string;
247
+ /** Wall-clock ms from QEMU spawn to REST-ready for the most recent boot. */
248
+ lastBootMs?: number;
249
+ /** Managed SSH key installed during provisioning, if any (issue #74). */
250
+ managedSshKey?: ManagedSshKey;
184
251
  }
185
252
 
186
253
  // --- Start Options ---
@@ -241,13 +308,38 @@ export interface StartOptions {
241
308
  portBase?: number;
242
309
  /** Services to exclude from port mappings (e.g. ["winbox", "api-ssl"]). */
243
310
  excludePorts?: ServiceName[];
244
- /** Additional custom port mappings appended to the default set. */
311
+ /** Additional host→guest port forwards appended to the default service set —
312
+ * the library equivalent of the CLI `--forward` flag. Each becomes one SLIRP
313
+ * `hostfwd` directive and only works over a `"user"` NIC.
314
+ *
315
+ * `proto` may be `"tcp"` or `"udp"`. To turn CLI-style spec strings into this
316
+ * array, use {@link parseForwardSpec} (single port) or {@link expandForwardSpec}
317
+ * (port ranges, e.g. dynamic UDP data ports). `WELL_KNOWN_GUEST_PORTS` /
318
+ * {@link lookupGuestPort} resolve guest port + proto for common service names.
319
+ *
320
+ * For *receiving* guest-originated UDP (no forward needed), see
321
+ * {@link ChrInstance.tzspGatewayIp} and `docs/networking-recipes.md`.
322
+ * @example
323
+ * extraPorts: [{ name: "snmp", host: 9161, guest: 161, proto: "udp" }]
324
+ * @example
325
+ * // dynamic UDP data ports via a range spec:
326
+ * extraPorts: expandForwardSpec("btest:9200-9210:2000-2010/udp")
327
+ */
245
328
  extraPorts?: PortMapping[];
246
329
  /** @deprecated Use `networks` instead. Kept for backward compatibility. */
247
330
  network?: NetworkMode;
248
- /** Network interfaces. Each entry becomes a NIC on the CHR.
249
- * Default (when omitted): single user-mode NIC.
250
- * When specified: count of entries = count of NICs (explicit control). */
331
+ /** Network interfaces — the library equivalent of repeated `--add-network` flags.
332
+ * Each entry becomes a NIC on the CHR, in order (so `networks[0]` is ether1).
333
+ * Default (when omitted): a single `"user"` NIC.
334
+ * When specified: count of entries = count of NICs (explicit control).
335
+ *
336
+ * Keep `"user"` first for management/`hostfwd`; add a second NIC for L2 work.
337
+ * See {@link NetworkSpecifier} for which specifier fits which traffic shape,
338
+ * and `docs/networking-recipes.md` / `docs/mndp.md` for runnable recipes.
339
+ * @example
340
+ * // management + host-visible L2 (e.g. to receive MNDP on the host):
341
+ * networks: ["user", { type: "socket-connect", port: hostTcpServerPort }]
342
+ */
251
343
  networks?: NetworkSpecifier[];
252
344
  /** Install OS-level dependencies (QEMU, firmware) automatically if missing. */
253
345
  installDeps?: boolean;
@@ -291,36 +383,138 @@ export interface ChrPorts {
291
383
  [key: string]: number;
292
384
  }
293
385
 
294
- export interface MachineDescriptor {
386
+ // --- Descriptor v1 (centrs interface contract) ---
387
+ //
388
+ // Live, credential-bearing connection-handoff shape for a *running* CHR instance —
389
+ // the quickchr <-> centrs interface contract (issue #71). Full contract, per-service
390
+ // mapping rules, and scope boundaries: docs/centrs-interface.md.
391
+
392
+ /** Canonical service IDs for {@link Descriptor.services}, matching centrs' `--via`
393
+ * values verbatim. Exported as constants for TS ergonomics
394
+ * (`SERVICE_IDS.nativeApi === "native-api"`) while the serialized JSON key stays the
395
+ * canonical string. See docs/centrs-interface.md "Service naming decision". */
396
+ export const SERVICE_IDS = {
397
+ restApi: "rest-api",
398
+ nativeApi: "native-api",
399
+ ssh: "ssh",
400
+ } as const;
401
+
402
+ /** Schema compatibility boundary for {@link Descriptor}. Changes within a version are
403
+ * additive-only (new optional fields/keys); bump only for a breaking restructure.
404
+ * See docs/centrs-interface.md "Forward-compat policy". */
405
+ export const QUICKCHR_DESCRIPTOR_VERSION = 1 as const;
406
+
407
+ /** Per-service endpoint shape, deliberately shared with centrs' internal
408
+ * `ServiceEndpoint` (tikoci/centrs#174) for `rest-api`/`native-api`. A consumer must
409
+ * gate on `available` before dialing — the `available: false` variant's extra fields
410
+ * are best-effort echoes, not a promise the endpoint is reachable.
411
+ * See docs/centrs-interface.md "ServiceEndpoint (rest-api, native-api)". */
412
+ export type ServiceEndpoint =
413
+ | {
414
+ available: true;
415
+ /** Hostname/IP — always `"127.0.0.1"` for quickchr's SLiRP-forwarded loopback
416
+ * ports. NOT a port; see the `PortMapping.host` naming-collision note in
417
+ * docs/centrs-interface.md. */
418
+ host: string;
419
+ port: number;
420
+ guestPort?: number;
421
+ transport: "tcp" | "udp";
422
+ /** true for https/apiSsl-backed endpoints, false otherwise. */
423
+ tls: boolean;
424
+ url?: string;
425
+ source?: { provider: "quickchr"; portMappingName?: string };
426
+ auth?: { username: string; password?: string; basic?: string; header?: string };
427
+ }
428
+ | {
429
+ available: false;
430
+ unavailableReason: string;
431
+ host?: string;
432
+ port?: number;
433
+ guestPort?: number;
434
+ transport?: "tcp" | "udp";
435
+ tls?: boolean;
436
+ url?: string;
437
+ source?: { provider: "quickchr"; portMappingName?: string };
438
+ };
439
+
440
+ /** SSH's per-endpoint shape. Extends the generic {@link ServiceEndpoint}'s
441
+ * available/host/port/transport/source discriminated union with an SSH-specific
442
+ * `auth` sub-shape substituted for the REST/native-api auth object — NOT an
443
+ * independent type. See docs/centrs-interface.md "SshServiceEndpoint". */
444
+ export type SshServiceEndpoint =
445
+ | ({ available: true } & Omit<Extract<ServiceEndpoint, { available: true }>, "auth"> & {
446
+ auth: {
447
+ /** `state.user?.name ?? "admin"`. */
448
+ username: string;
449
+ /** `managedSshKey.privateKeyPath` — ONLY set when `batchVerified === true`.
450
+ * Never emit an unverified path. */
451
+ privateKeyPath?: string;
452
+ /** Modes centrs *may* try (broader, not all vouched-for). */
453
+ modes: Array<"private-key" | "agent-or-config" | "password">;
454
+ /** The gate centrs enforces for `--via ssh` / `transfer --via sftp` — only
455
+ * modes quickchr actually vouches for. */
456
+ batchModes: Array<"private-key" | "agent-or-config">;
457
+ passwordAvailable?: boolean;
458
+ };
459
+ })
460
+ | Extract<ServiceEndpoint, { available: false }>;
461
+
462
+ /** Generic extra port-forward listing beyond the three canonical `services` keys
463
+ * (e.g. `winbox`, or a user's `extraPorts`). Descriptor-specific shape — do not leak
464
+ * internal `PortMapping` field names (there, `host` is a port number; here it's a
465
+ * hostname). See docs/centrs-interface.md "CustomForward". */
466
+ export interface CustomForward {
295
467
  name: string;
468
+ transport: "tcp" | "udp";
469
+ host: string;
470
+ hostPort: number;
471
+ guestPort: number;
472
+ }
473
+
474
+ /** Topology-only awareness of a machine's network interfaces — declarative, not a
475
+ * resolved connection fact. Do not add `available`/`host`/`port` here; that would
476
+ * imply a resolved-connection promise quickchr can't keep for DHCP-assigned segments.
477
+ * See docs/centrs-interface.md "Multi-network awareness". */
478
+ export interface NetworkTopologyEntry {
479
+ /** Matches `NetworkConfig.id` / boot order (`"net0"` = ether1). */
480
+ id: string;
481
+ /** Verbatim declared intent from `state.networks[].specifier`. */
482
+ specifier: NetworkSpecifier;
483
+ }
484
+
485
+ /** Live, credential-bearing connection-handoff descriptor for a *running* CHR
486
+ * instance — the quickchr <-> centrs interface contract (issue #71).
487
+ * `descriptor()` only ever returns this running shape; stopped machines throw
488
+ * `MACHINE_STOPPED` as today. A missing machine name never reaches this type —
489
+ * `QuickCHR.get(name)` returns `undefined` for that case (a typed absent value;
490
+ * no separate `MACHINE_NOT_FOUND` throw from this surface).
491
+ * See docs/centrs-interface.md "Descriptor v1 shape". */
492
+ export interface Descriptor {
493
+ descriptorVersion: 1;
494
+ quickchr: { packageVersion: string };
296
495
  status: "running";
496
+
497
+ name: string;
297
498
  version: string;
298
499
  arch: Arch;
299
500
  cpu: number;
300
501
  mem: number;
301
502
  pid: number | null;
302
- ports: ChrPorts;
303
- portMappings: Record<string, PortMapping>;
304
- urls: {
305
- http: string;
306
- rest: string;
307
- restBase: string;
308
- https?: string;
309
- ssh?: string;
310
- api?: string;
311
- apiSsl?: string;
312
- winbox?: string;
313
- };
314
- auth: {
315
- user: string;
316
- password: string;
317
- basic: string;
318
- header: string;
319
- };
320
- env: Record<string, string>;
321
503
  machineDir: string;
322
504
  createdAt: string;
323
505
  lastStartedAt: string | null;
506
+
507
+ services: {
508
+ "rest-api": ServiceEndpoint;
509
+ "native-api": ServiceEndpoint;
510
+ ssh: SshServiceEndpoint;
511
+ };
512
+
513
+ /** Only present when quickchr has forwards beyond the three `services` keys
514
+ * above (e.g. `winbox`, or a user's `extraPorts`). */
515
+ customForwards?: CustomForward[];
516
+ /** Topology-only; SLiRP-forwarded connection facts stay in `services`. */
517
+ networks?: NetworkTopologyEntry[];
324
518
  }
325
519
 
326
520
  /** Return value of ChrInstance.queryLoad(). */
@@ -361,10 +555,19 @@ export interface ChrInstance {
361
555
  * Pass directly to tshark: `tshark -i ${instance.captureInterface} -f "udp port 37008"`
362
556
  * Only meaningful when the instance has a user-mode (`"user"`) network interface. */
363
557
  captureInterface: string;
364
- /** QEMU user-mode networking gateway IP — the host's address as seen from inside the VM.
365
- * Use this as the RouterOS `/tool/sniffer` streaming-server target so TZSP traffic
366
- * reaches the host. Always `"10.0.2.2"` for QEMU slirp user-mode networking.
558
+ /** QEMU user-mode (SLIRP) gateway IP — the host's address as seen from inside the VM.
559
+ * Always `"10.0.2.2"` for QEMU slirp user-mode networking.
560
+ *
561
+ * This is the general **guest→host UDP** primitive, not just for TZSP: any
562
+ * datagram the guest sends to `10.0.2.2:<port>` is delivered to a host process
563
+ * bound on loopback at `<port>` (capture it on {@link captureInterface}) with
564
+ * **no `hostfwd` and no extra NIC**. The host socket must be left **unconnected**
565
+ * (use `recvfrom`, not `connect`) — a connected socket filters out the
566
+ * gateway-origin datagrams. This covers guest-originated UDP (syslog, NetFlow,
567
+ * TZSP) and a guest server replying to the gateway. Recipe + lab evidence:
568
+ * `docs/networking-recipes.md`, `examples/udp-gateway/`.
367
569
  * @example
570
+ * // RouterOS TZSP sniffer streaming to the host:
368
571
  * await instance.exec(`/tool/sniffer/set streaming-server=${instance.tzspGatewayIp}:37008`);
369
572
  */
370
573
  tzspGatewayIp: string;
@@ -470,8 +673,9 @@ export interface ChrInstance {
470
673
  * Bun.spawn(["bun", "my-script.ts"], { env: { ...process.env, ...await chr.subprocessEnv() } })
471
674
  */
472
675
  subprocessEnv(): Promise<Record<string, string>>;
473
- /** Build a stable machine-readable connection descriptor for a running instance. */
474
- descriptor(): Promise<MachineDescriptor>;
676
+ /** Build a stable machine-readable connection descriptor for a running instance —
677
+ * the quickchr <-> centrs interface contract (issue #71, docs/centrs-interface.md). */
678
+ descriptor(): Promise<Descriptor>;
475
679
 
476
680
  /** Sample QEMU guest load from the monitor. Returns null when the monitor is
477
681
  * unavailable (machine stopped or running in foreground mode). */
@@ -575,7 +779,10 @@ export type ErrorCode =
575
779
  | "INVALID_FORWARD_SPEC"
576
780
  | "PROVISIONING_VERSION_UNSUPPORTED"
577
781
  | "INSUFFICIENT_DISK_SPACE"
578
- | "STATE_ERROR";
782
+ | "STATE_ERROR"
783
+ | "INVALID_SIZE_STRING"
784
+ | "INVALID_SETTING_KEY"
785
+ | "INVALID_SETTING_VALUE";
579
786
 
580
787
  export class QuickCHRError extends Error {
581
788
  code: ErrorCode;