diff --git a/docs/centrs-interface.md b/docs/centrs-interface.md index af064b1..5af5f6b 100644 --- a/docs/centrs-interface.md +++ b/docs/centrs-interface.md @@ -95,7 +95,7 @@ interface Descriptor { descriptorVersion: 1; quickchr: { packageVersion: string }; // no apiVersion — see "Dropped fields" status: "running"; // descriptor() only ever returns a running shape; - // stopped/missing throw MACHINE_STOPPED / MACHINE_NOT_FOUND as today + // stopped machines throw MACHINE_STOPPED as today // machine identity — carried over from the current MachineDescriptor as top-level fields name: string; @@ -119,6 +119,15 @@ interface Descriptor { } ``` +**Missing-machine semantics:** a missing machine name never reaches this type at all — +`QuickCHR.get(name)` returns `undefined` for that case, which is already a typed (TS) +absent-value signal. There is no separate `MACHINE_NOT_FOUND` throw from `descriptor()` +or anywhere else reachable from it; a consumer should treat `QuickCHR.get(name) === +undefined` as its own not-found case rather than expecting a thrown error. (Confirmed +during implementation: no code path existed that threw `MACHINE_NOT_FOUND` for a missing +name before this work, and none was added — inventing a `getOrThrow`-style method for +this wasn't asked for and isn't needed.) + ### `ServiceEndpoint` (rest-api, native-api) One per-service endpoint shape. Centrs reuses this same internal type for TikTOML @@ -248,17 +257,19 @@ Mapping rules: emit an unverified path. - `auth.batchModes` = `["private-key"]` when `batchVerified === true`, else `[]`. Never advertise `"private-key"` in `batchModes` off an absent/unverified key. -- `"agent-or-config"` may appear in `batchModes` independent of `managedSshKey` — that - is host `ssh-agent` / `~/.ssh/config` policy, which quickchr cannot verify. Include - it as a mode centrs *may* try, not one quickchr vouches for. +- `"agent-or-config"` reflects host `ssh-agent` / `~/.ssh/config` policy, which + quickchr cannot verify either way — it is always included in `modes` (a mode centrs + *may* try) but **never** added to `batchModes` (implemented: `batchModes` only ever + contains `"private-key"` when `batchVerified === true`, or is empty — matching this + doc's own Done-when example for the unverified/absent case, `batchModes: []`). - `auth.username` = `state.user?.name` (the managed/provisioned user, e.g. `"quickchr"`) when a managed user exists, else `"admin"`. - `auth.passwordAvailable` = whether the credential store has a resolvable password for that user today (same source `resolveAuth`/`resolveCreds` use — `src/lib/auth.ts`). - **Absent `managedSshKey`** (machine started with `--no-secure-login`, or an explicit `--user`/`--password` override that skips managed-key install per - `provision.ts:396`): no key path, `batchModes: []`, and `modes` still includes - `"password"` when `passwordAvailable`. + `provision.ts:484-489` / `:506` / `:528`): no key path, `batchModes: []`, and `modes` + still includes `"password"` when `passwordAvailable`. - Algorithm/version grounding: `test/lab/ssh-keys/REPORT.md`. ### TLS @@ -267,11 +278,24 @@ Mapping rules: quickchr already tracks plain/TLS pairs separately (`ChrPorts.http`/`https`, `.api`/`.apiSsl` — `src/lib/types.ts:376`). Populate: -- `services["rest-api"]` → pick `https`/`http` per the existing REST-URL preference - logic; set `tls` to match which was chosen. +- `services["rest-api"]` → pick `https`/`http`; set `tls` to match which was chosen. - `services["native-api"]` → pick `apiSsl`/`api`; set `tls` to match. - `services.ssh` → always `tls: false`. +**Implementation note:** there was no pre-existing "REST-URL preference logic" to reuse +here — before this issue, `restUrl` (used for every REST call, and for the old flat +descriptor's `urls.http`/`rest`/`restBase`) was unconditionally built from `ports.http`; +`https`/`apiSsl` were just additive fields, never preferred. The secure-preferred, +plain-fallback logic above was written fresh as part of implementing this contract. As a +side effect it also closes a latent bug: `excludePorts` had no guard against excluding +`"http"` while keeping `"https"`, which previously left the REST base pointing at a port +that didn't exist — `services["rest-api"]` now falls back to the surviving port instead. + +`services["rest-api"].url` includes the `/rest` path suffix (e.g. +`https://127.0.0.1:19101/rest`) since that's the actual base a consumer dials for +RouterOS's REST endpoints; `services["native-api"].url` has no such suffix (it's a raw +TCP/TLS socket, not an HTTP path). + Consumer scope note (do **not** build this into quickchr): centrs auto-preferring the secure variant when both are open is a centrs-side (`tikoci/centrs#134`) decision. Leave a pointer comment; don't add preference logic to quickchr. @@ -309,6 +333,13 @@ name. In the new shapes, `host` is always the literal `"127.0.0.1"` string; one for this issue. - **Old flat `ports` / `urls` / top-level `auth`** — replaced by `services`. (The restructure is allowed; see below.) +- **`env` — dropped, not carried into `Descriptor` at all.** The old `MachineDescriptor` + embedded a subprocess-env map (`env: Record`, built the same way as + `ChrInstance.subprocessEnv()`) and the CLI's `quickchr env` command read it via + `descriptor().env` instead of calling `subprocessEnv()` directly. Fixed as part of + this implementation: `quickchr env` now calls `instance.subprocessEnv()` directly, + matching what `README.md` already documented. `subprocessEnv()` itself is unchanged + and remains the one place to get env-var-shaped connection facts. --- diff --git a/examples/harness/harness.ts b/examples/harness/harness.ts index 7c2a57d..b69212a 100755 --- a/examples/harness/harness.ts +++ b/examples/harness/harness.ts @@ -7,7 +7,7 @@ * read `machine.json`. Use the stable connection surface: * * - `instance.subprocessEnv()` → env vars (`URLBASE`, `BASICAUTH`, …) for a child - * - `instance.descriptor()` → a structured `{ urls, auth, ports, status, … }` + * - `instance.descriptor()` → a structured `{ services, status, … }` (issue #71) * * Both are **secret-bearing** (real credentials) — treat their output like a * password: don't log it, don't write it to artifacts. `BASICAUTH` is the raw @@ -38,7 +38,9 @@ if (import.meta.main) { // The structured descriptor — what a harness records instead of machine.json. const desc = await chr.descriptor(); check(desc.status === "running", "descriptor status should be running"); - check(desc.urls.rest.includes("/rest"), "descriptor REST url should contain /rest"); + const restApi = desc.services["rest-api"]; + check(restApi.available, "rest-api service should be available"); + check(restApi.available && restApi.url?.includes("/rest") === true, "descriptor REST url should contain /rest"); // Hand the connection env to a separate process; let it talk to the CHR. const env = await chr.subprocessEnv(); diff --git a/src/cli/index.ts b/src/cli/index.ts index f8ddb88..b94f2eb 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1490,7 +1490,7 @@ function shellQuoteEnv(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } -async function getRunningDescriptor(name: string) { +async function getRunningInstance(name: string) { const { QuickCHR } = await import("../lib/quickchr.ts"); const { machineNotFoundMessage } = await import("./format.ts"); const instance = QuickCHR.get(name); @@ -1498,6 +1498,18 @@ async function getRunningDescriptor(name: string) { console.error(machineNotFoundMessage(name)); process.exit(1); } + if (instance.state.status !== "running") { + const { QuickCHRError } = await import("../lib/types.ts"); + throw new QuickCHRError( + "MACHINE_STOPPED", + `Machine "${name}" must be running to inspect its connection environment (current status: ${instance.state.status}).`, + ); + } + return instance; +} + +async function getRunningDescriptor(name: string) { + const instance = await getRunningInstance(name); return instance.descriptor(); } @@ -1520,12 +1532,13 @@ async function cmdEnv(argv: string[] = []) { console.error("Usage: quickchr env [--json]"); process.exit(1); } - const descriptor = await getRunningDescriptor(name); + const instance = await getRunningInstance(name); + const env = await instance.subprocessEnv(); if (asJson) { - console.log(JSON.stringify(descriptor.env, null, 2)); + console.log(JSON.stringify(env, null, 2)); return; } - for (const [key, value] of Object.entries(descriptor.env)) { + for (const [key, value] of Object.entries(env)) { console.log(`${key}=${shellQuoteEnv(value)}`); } } @@ -2846,8 +2859,9 @@ List all CHR instances or show detailed info for one. case "inspect": console.log(`quickchr inspect [--json] -Print a stable JSON descriptor for a running CHR instance: ports, URLs, -credentials, status, and subprocess env vars. +Print a stable JSON descriptor for a running CHR instance: status, machine +identity, and per-service connection facts (rest-api, native-api, ssh). +Use 'quickchr env' for subprocess env vars. Name of a running CHR instance. --json Accepted for parity; output is always JSON.`); diff --git a/src/index.ts b/src/index.ts index 455d80d..dbba0b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,8 @@ export type { Channel, ChrInstance, ChrPorts, + CustomForward, + Descriptor, DeviceModeOptions, DoctorCheck, DoctorResult, @@ -44,11 +46,11 @@ export type { LicenseLevel, LicenseOptions, MachineConfig, - MachineDescriptor, MachineState, NetworkConfig, NetworkMode, NetworkSpecifier, + NetworkTopologyEntry, PackageManager, PlatformInfo, PortMapping, @@ -59,12 +61,14 @@ export type { QgaNetworkIpAddress, QgaOsInfo, QgaTimezone, + ServiceEndpoint, ServiceName, SnapshotInfo, + SshServiceEndpoint, StartOptions, } from "./lib/types.ts"; -export { QuickCHRError } from "./lib/types.ts"; +export { QuickCHRError, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./lib/types.ts"; export type { ErrorCode, HostInterface } from "./lib/types.ts"; // Interface detection diff --git a/src/lib/quickchr.ts b/src/lib/quickchr.ts index 5eb240f..73c7936 100644 --- a/src/lib/quickchr.ts +++ b/src/lib/quickchr.ts @@ -7,19 +7,25 @@ import type { Channel, ChrInstance, ChrLoadSample, + CustomForward, + Descriptor, DeviceModeOptions, DoctorResult, ExecOptions, ExecResult, LicenseInput, LicenseLevel, - MachineDescriptor, MachineState, + NetworkTopologyEntry, + PortMapping, QgaCommand, + ServiceEndpoint, SnapshotInfo, + SshServiceEndpoint, StartOptions, } from "./types.ts"; -import { QuickCHRError, ARCHES, CHANNELS } from "./types.ts"; +import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./types.ts"; +import packageJson from "../../package.json"; import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts"; import { resolveVersion, @@ -56,7 +62,7 @@ import { provision } from "./provision.ts"; import { renewLicense, getLicenseInfo } from "./license.ts"; import { resolveAuth, resolveCreds } from "./auth.ts"; import { scpPush, scpPull } from "./scp.ts"; -import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts"; +import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts"; import { restExecute } from "./exec.ts"; import { qgaExec } from "./qga.ts"; import { consoleExec } from "./console.ts"; @@ -242,46 +248,151 @@ function createInstance(state: MachineState): ChrInstance { BASICAUTH: rawCreds, }; }; - const buildDescriptor = (): MachineDescriptor => { + // Service keys beyond these five are surfaced as `customForwards` (winbox, plus + // any user-added extraPorts) — see docs/centrs-interface.md "CustomForward". + const CANONICAL_PORT_NAMES = new Set(["http", "https", "api", "api-ssl", "ssh"]); + + const buildDescriptor = (): Descriptor => { if (state.status !== "running") { throw new QuickCHRError( "MACHINE_STOPPED", `Machine "${state.name}" must be running to inspect its connection environment (current status: ${state.status}).`, ); } + const auth = resolveAuth(state); - const env = buildEnv(); - const basic = env.QUICKCHR_AUTH ?? `${auth.user}:`; + const creds = resolveCreds(state); + const basic = `${creds.user}:${creds.password}`; + const storedCreds = getInstanceCredentials(state.name); + // resolveAuth/resolveCreds never throw — when disableAdmin is true and no + // provisioned/stored user exists, they still fall back to the meaningless + // admin:"" tuple (auth.ts docstring: "the caller will get a 401"). Don't + // report a service available:true off that fallback alone. + const disableAdminLockout = state.disableAdmin === true && !state.user && !storedCreds; + // A provisioned user whose password is the STORED_IN_SECRETS_PASSWORD sentinel + // only resolves a real password when the per-instance credential store actually + // has an entry — otherwise resolveAuth/resolveCreds fall through to returning the + // literal sentinel string as the "password". Don't report available:true (or leak + // the sentinel as a usable password) off that unresolvable case either, regardless + // of disableAdmin. + const sentinelUnresolvable = state.user?.password === STORED_IN_SECRETS_PASSWORD && !storedCreds; + const credentialsAvailable = !disableAdminLockout && !sentinelUnresolvable; + + const buildHttpService = ( + securePM: PortMapping | undefined, + plainPM: PortMapping | undefined, + serviceLabel: string, + scheme: { secure: string; plain: string }, + authObj: { username: string; password?: string; basic?: string; header?: string }, + urlSuffix = "", + ): ServiceEndpoint => { + const chosen = securePM ?? plainPM; + if (!chosen) { + return { available: false, unavailableReason: `no forwarded port for ${serviceLabel}` }; + } + const tls = chosen === securePM; + const echo = { + host: "127.0.0.1", + port: chosen.host, + guestPort: chosen.guest, + transport: chosen.proto, + tls, + url: `${tls ? scheme.secure : scheme.plain}://127.0.0.1:${chosen.host}${urlSuffix}`, + source: { provider: "quickchr" as const, portMappingName: chosen.name }, + }; + if (!credentialsAvailable) { + return { available: false, unavailableReason: "admin disabled, no user provisioned", ...echo }; + } + return { available: true, ...echo, auth: authObj }; + }; + + // TLS preference is new logic (no pre-existing REST-URL preference to "keep" — + // see docs/centrs-interface.md's TLS section): prefer the secure port when its + // forward exists, fall back to plain, `available:false` only if neither does. + // This also closes a latent bug where excluding "http" while keeping "https" + // left restUrl pointing at a port that doesn't exist. + const restApi = buildHttpService( + state.ports.https, + state.ports.http, + "rest-api", + { secure: "https", plain: "http" }, + { username: creds.user, password: creds.password, basic, header: auth.header }, + "/rest", + ); + const nativeApi = buildHttpService( + state.ports["api-ssl"], + state.ports.api, + "native-api", + { secure: "tls", plain: "tcp" }, + { username: creds.user, password: creds.password }, + ); + + const sshPM = state.ports.ssh; + const sshUsername = state.user?.name ?? "admin"; + const managedKey = state.managedSshKey; + const batchVerified = managedKey?.batchVerified === true; + const sshModes: Array<"private-key" | "agent-or-config" | "password"> = []; + if (managedKey) sshModes.push("private-key"); + sshModes.push("agent-or-config"); + if (credentialsAvailable) sshModes.push("password"); + + const sshService: SshServiceEndpoint = !sshPM + ? { available: false, unavailableReason: "no forwarded port for ssh" } + : { + available: true, + host: "127.0.0.1", + port: sshPM.host, + guestPort: sshPM.guest, + transport: sshPM.proto, + tls: false, + url: `ssh://${sshUsername}@127.0.0.1:${sshPM.host}`, + source: { provider: "quickchr" as const, portMappingName: "ssh" }, + auth: { + username: sshUsername, + ...(batchVerified && managedKey ? { privateKeyPath: managedKey.privateKeyPath } : {}), + modes: sshModes, + // Only "private-key" is ever vouched for here, and only once verified — + // "agent-or-config" is host ssh-agent/~/.ssh/config policy quickchr has + // no way to check, so it stays out of batchModes (Done-when's own + // unverified/absent-key example expects batchModes: [], not + // ["agent-or-config"]). + batchModes: batchVerified ? ["private-key"] : [], + passwordAvailable: credentialsAvailable, + }, + }; + + const customForwards: CustomForward[] = Object.entries(state.ports) + .filter(([name]) => !CANONICAL_PORT_NAMES.has(name)) + .map(([name, pm]) => ({ + name, + transport: pm.proto, + host: "127.0.0.1", + hostPort: pm.host, + guestPort: pm.guest, + })); + + const networks: NetworkTopologyEntry[] = state.networks.map((n) => ({ id: n.id, specifier: n.specifier })); + return { - name: state.name, + descriptorVersion: QUICKCHR_DESCRIPTOR_VERSION, + quickchr: { packageVersion: packageJson.version }, status: "running", + name: state.name, version: state.version, arch: state.arch, cpu: state.cpu, mem: state.mem, pid: state.pid ?? null, - ports, - portMappings: state.ports, - urls: { - http: restUrl, - rest: `${restUrl}/rest`, - restBase: `${restUrl}/rest`, - https: ports.https ? `https://127.0.0.1:${ports.https}` : undefined, - ssh: ports.ssh ? `ssh://${auth.user}@127.0.0.1:${ports.ssh}` : undefined, - api: ports.api ? `tcp://127.0.0.1:${ports.api}` : undefined, - apiSsl: ports.apiSsl ? `tls://127.0.0.1:${ports.apiSsl}` : undefined, - winbox: ports.winbox ? `tcp://127.0.0.1:${ports.winbox}` : undefined, - }, - auth: { - user: auth.user, - password: basic.slice(auth.user.length + 1), - basic, - header: auth.header, - }, - env, machineDir: state.machineDir, createdAt: state.createdAt, lastStartedAt: state.lastStartedAt ?? null, + services: { + [SERVICE_IDS.restApi]: restApi, + [SERVICE_IDS.nativeApi]: nativeApi, + [SERVICE_IDS.ssh]: sshService, + }, + ...(customForwards.length > 0 ? { customForwards } : {}), + networks, }; }; @@ -636,7 +747,7 @@ function createInstance(state: MachineState): ChrInstance { return buildEnv(); }, - async descriptor(): Promise { + async descriptor(): Promise { return buildDescriptor(); }, diff --git a/src/lib/types.ts b/src/lib/types.ts index de5711b..9b6b433 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -383,36 +383,138 @@ export interface ChrPorts { [key: string]: number; } -export interface MachineDescriptor { +// --- Descriptor v1 (centrs interface contract) --- +// +// Live, credential-bearing connection-handoff shape for a *running* CHR instance — +// the quickchr <-> centrs interface contract (issue #71). Full contract, per-service +// mapping rules, and scope boundaries: docs/centrs-interface.md. + +/** Canonical service IDs for {@link Descriptor.services}, matching centrs' `--via` + * values verbatim. Exported as constants for TS ergonomics + * (`SERVICE_IDS.nativeApi === "native-api"`) while the serialized JSON key stays the + * canonical string. See docs/centrs-interface.md "Service naming decision". */ +export const SERVICE_IDS = { + restApi: "rest-api", + nativeApi: "native-api", + ssh: "ssh", +} as const; + +/** Schema compatibility boundary for {@link Descriptor}. Changes within a version are + * additive-only (new optional fields/keys); bump only for a breaking restructure. + * See docs/centrs-interface.md "Forward-compat policy". */ +export const QUICKCHR_DESCRIPTOR_VERSION = 1 as const; + +/** Per-service endpoint shape, deliberately shared with centrs' internal + * `ServiceEndpoint` (tikoci/centrs#174) for `rest-api`/`native-api`. A consumer must + * gate on `available` before dialing — the `available: false` variant's extra fields + * are best-effort echoes, not a promise the endpoint is reachable. + * See docs/centrs-interface.md "ServiceEndpoint (rest-api, native-api)". */ +export type ServiceEndpoint = + | { + available: true; + /** Hostname/IP — always `"127.0.0.1"` for quickchr's SLiRP-forwarded loopback + * ports. NOT a port; see the `PortMapping.host` naming-collision note in + * docs/centrs-interface.md. */ + host: string; + port: number; + guestPort?: number; + transport: "tcp" | "udp"; + /** true for https/apiSsl-backed endpoints, false otherwise. */ + tls: boolean; + url?: string; + source?: { provider: "quickchr"; portMappingName?: string }; + auth?: { username: string; password?: string; basic?: string; header?: string }; + } + | { + available: false; + unavailableReason: string; + host?: string; + port?: number; + guestPort?: number; + transport?: "tcp" | "udp"; + tls?: boolean; + url?: string; + source?: { provider: "quickchr"; portMappingName?: string }; + }; + +/** SSH's per-endpoint shape. Extends the generic {@link ServiceEndpoint}'s + * available/host/port/transport/source discriminated union with an SSH-specific + * `auth` sub-shape substituted for the REST/native-api auth object — NOT an + * independent type. See docs/centrs-interface.md "SshServiceEndpoint". */ +export type SshServiceEndpoint = + | ({ available: true } & Omit, "auth"> & { + auth: { + /** `state.user?.name ?? "admin"`. */ + username: string; + /** `managedSshKey.privateKeyPath` — ONLY set when `batchVerified === true`. + * Never emit an unverified path. */ + privateKeyPath?: string; + /** Modes centrs *may* try (broader, not all vouched-for). */ + modes: Array<"private-key" | "agent-or-config" | "password">; + /** The gate centrs enforces for `--via ssh` / `transfer --via sftp` — only + * modes quickchr actually vouches for. */ + batchModes: Array<"private-key" | "agent-or-config">; + passwordAvailable?: boolean; + }; + }) + | Extract; + +/** Generic extra port-forward listing beyond the three canonical `services` keys + * (e.g. `winbox`, or a user's `extraPorts`). Descriptor-specific shape — do not leak + * internal `PortMapping` field names (there, `host` is a port number; here it's a + * hostname). See docs/centrs-interface.md "CustomForward". */ +export interface CustomForward { name: string; + transport: "tcp" | "udp"; + host: string; + hostPort: number; + guestPort: number; +} + +/** Topology-only awareness of a machine's network interfaces — declarative, not a + * resolved connection fact. Do not add `available`/`host`/`port` here; that would + * imply a resolved-connection promise quickchr can't keep for DHCP-assigned segments. + * See docs/centrs-interface.md "Multi-network awareness". */ +export interface NetworkTopologyEntry { + /** Matches `NetworkConfig.id` / boot order (`"net0"` = ether1). */ + id: string; + /** Verbatim declared intent from `state.networks[].specifier`. */ + specifier: NetworkSpecifier; +} + +/** Live, credential-bearing connection-handoff descriptor for a *running* CHR + * instance — the quickchr <-> centrs interface contract (issue #71). + * `descriptor()` only ever returns this running shape; stopped machines throw + * `MACHINE_STOPPED` as today. A missing machine name never reaches this type — + * `QuickCHR.get(name)` returns `undefined` for that case (a typed absent value; + * no separate `MACHINE_NOT_FOUND` throw from this surface). + * See docs/centrs-interface.md "Descriptor v1 shape". */ +export interface Descriptor { + descriptorVersion: 1; + quickchr: { packageVersion: string }; status: "running"; + + name: string; version: string; arch: Arch; cpu: number; mem: number; pid: number | null; - ports: ChrPorts; - portMappings: Record; - urls: { - http: string; - rest: string; - restBase: string; - https?: string; - ssh?: string; - api?: string; - apiSsl?: string; - winbox?: string; - }; - auth: { - user: string; - password: string; - basic: string; - header: string; - }; - env: Record; machineDir: string; createdAt: string; lastStartedAt: string | null; + + services: { + "rest-api": ServiceEndpoint; + "native-api": ServiceEndpoint; + ssh: SshServiceEndpoint; + }; + + /** Only present when quickchr has forwards beyond the three `services` keys + * above (e.g. `winbox`, or a user's `extraPorts`). */ + customForwards?: CustomForward[]; + /** Topology-only; SLiRP-forwarded connection facts stay in `services`. */ + networks?: NetworkTopologyEntry[]; } /** Return value of ChrInstance.queryLoad(). */ @@ -571,8 +673,9 @@ export interface ChrInstance { * Bun.spawn(["bun", "my-script.ts"], { env: { ...process.env, ...await chr.subprocessEnv() } }) */ subprocessEnv(): Promise>; - /** Build a stable machine-readable connection descriptor for a running instance. */ - descriptor(): Promise; + /** Build a stable machine-readable connection descriptor for a running instance — + * the quickchr <-> centrs interface contract (issue #71, docs/centrs-interface.md). */ + descriptor(): Promise; /** Sample QEMU guest load from the monitor. Returns null when the monitor is * unavailable (machine stopped or running in foreground mode). */ diff --git a/test/integration/provisioning.test.ts b/test/integration/provisioning.test.ts index 6135a00..a8a3b36 100644 --- a/test/integration/provisioning.test.ts +++ b/test/integration/provisioning.test.ts @@ -486,6 +486,17 @@ describe.skipIf(SKIP)("SSH key provisioning", () => { expect(mk?.batchVerified).toBe(true); } + // Close the #71 loop: descriptor() must surface the same batchVerified fact + // as a usable private-key batch mode, not just the raw managedSshKey state. + const descriptor = await instance.descriptor(); + const sshService = descriptor.services.ssh; + expect(sshService.available).toBe(true); + if (sshService.available) { + expect(sshService.auth.batchModes).toEqual(["private-key"]); + expect(sshService.auth.privateKeyPath).toBe(join(sshDir, "id_ed25519")); + expect(sshService.auth.username).toBe("quickchr"); + } + // Independent grounding of that flag: a real host-OpenSSH batch login // (BatchMode=yes, PasswordAuthentication=no) with the managed key must work. const login = Bun.spawnSync( diff --git a/test/unit/cli-env-inspect.test.ts b/test/unit/cli-env-inspect.test.ts index 6f9879a..8ab7956 100644 --- a/test/unit/cli-env-inspect.test.ts +++ b/test/unit/cli-env-inspect.test.ts @@ -80,7 +80,7 @@ afterEach(() => { }); describe("CLI env/inspect descriptors", () => { - test("inspect --json prints stable running machine descriptor", async () => { + test("inspect --json prints the v1 descriptor via the CLI binary", async () => { writeMachine(machineState("router", "running")); const result = await runQuickchr(["inspect", "router", "--json"]); @@ -89,45 +89,25 @@ describe("CLI env/inspect descriptors", () => { expect(result.stderr).toBe(""); const descriptor = JSON.parse(result.stdout); expect(descriptor).toMatchObject({ + descriptorVersion: 1, name: "router", status: "running", version: "7.22.1", arch: "x86", - ports: { - http: 19100, - ssh: 19102, - apiSsl: 19104, - winbox: 19105, - }, - urls: { - http: "http://127.0.0.1:19100", - rest: "http://127.0.0.1:19100/rest", - restBase: "http://127.0.0.1:19100/rest", - ssh: "ssh://api-user@127.0.0.1:19102", - }, - auth: { - user: "api-user", - password: "secret", - basic: "api-user:secret", - }, - env: { - QUICKCHR_NAME: "router", - QUICKCHR_REST_URL: "http://127.0.0.1:19100", - QUICKCHR_REST_BASE: "http://127.0.0.1:19100/rest", - QUICKCHR_SSH_PORT: "19102", - QUICKCHR_AUTH: "api-user:secret", - URLBASE: "http://127.0.0.1:19100/rest", - BASICAUTH: "api-user:secret", - }, - portMappings: { - http: { host: 19100, guest: 80, proto: "tcp" }, + services: { + "rest-api": { available: true, tls: true, port: 19101 }, + "native-api": { available: true, tls: true, port: 19104 }, + ssh: { available: true, tls: false, port: 19102 }, }, }); - expect(descriptor.auth.header).toMatch(/^Basic /); + expect(descriptor.quickchr).toHaveProperty("packageVersion"); + expect(descriptor.services["rest-api"].auth.basic).toBe("api-user:secret"); + expect(descriptor.services["rest-api"].auth.header).toMatch(/^Basic /); + expect(descriptor.customForwards).toEqual([{ name: "winbox", transport: "tcp", host: "127.0.0.1", hostPort: 19105, guestPort: 8291 }]); expect(descriptor.lastStartedAt).toBe("2026-01-01T00:01:00.000Z"); }); - test("env prints subprocess environment for a running machine", async () => { + test("env prints subprocess environment for a running machine (decoupled from descriptor())", async () => { writeMachine(machineState("router", "running")); const result = await runQuickchr(["env", "router"]); @@ -150,15 +130,11 @@ describe("CLI env/inspect descriptors", () => { expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); const descriptor = JSON.parse(result.stdout); - expect(descriptor.auth).toMatchObject({ - user: "api-user", + expect(descriptor.services["rest-api"].auth).toMatchObject({ + username: "api-user", password: "from-store", basic: "api-user:from-store", }); - expect(descriptor.env).toMatchObject({ - QUICKCHR_AUTH: "api-user:from-store", - BASICAUTH: "api-user:from-store", - }); }); test("inspect refuses stopped machines with a clean error", async () => { @@ -172,4 +148,15 @@ describe("CLI env/inspect descriptors", () => { expect(result.stderr).toContain("must be running"); expect(result.stderr).toContain("current status: stopped"); }); + + test("env also refuses stopped machines (subprocessEnv() has no guard of its own)", async () => { + writeMachine(machineState("stopped-router", "stopped")); + + const result = await runQuickchr(["env", "stopped-router"]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Error [MACHINE_STOPPED]"); + expect(result.stderr).toContain("must be running"); + }); }); diff --git a/test/unit/descriptor.test.ts b/test/unit/descriptor.test.ts new file mode 100644 index 0000000..bb72e03 --- /dev/null +++ b/test/unit/descriptor.test.ts @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import packageJson from "../../package.json"; +import { STORED_IN_SECRETS_PASSWORD } from "../../src/lib/credentials.ts"; +import { QuickCHR } from "../../src/lib/quickchr.ts"; +import type { MachineState } from "../../src/lib/types.ts"; +import { QUICKCHR_DESCRIPTOR_VERSION } from "../../src/lib/types.ts"; + +const TEST_DIR = join(import.meta.dir, ".tmp-descriptor-test"); +const originalDataDir = process.env.QUICKCHR_DATA_DIR; + +function baseState(name: string, overrides: Partial = {}): MachineState { + const machineDir = join(TEST_DIR, "machines", name); + return { + name, + version: "7.22.1", + arch: "x86", + cpu: 1, + mem: 512, + networks: [{ specifier: "user", id: "net0" }], + ports: { + http: { name: "http", host: 19100, guest: 80, proto: "tcp" }, + https: { name: "https", host: 19101, guest: 443, proto: "tcp" }, + ssh: { name: "ssh", host: 19102, guest: 22, proto: "tcp" }, + api: { name: "api", host: 19103, guest: 8728, proto: "tcp" }, + "api-ssl": { name: "api-ssl", host: 19104, guest: 8729, proto: "tcp" }, + winbox: { name: "winbox", host: 19105, guest: 8291, proto: "tcp" }, + }, + packages: [], + user: { name: "quickchr", password: "secret" }, + secureLogin: true, + portBase: 19100, + excludePorts: [], + extraPorts: [], + createdAt: "2026-01-01T00:00:00.000Z", + lastStartedAt: "2026-01-01T00:01:00.000Z", + status: "running", + pid: process.pid, + machineDir, + ...overrides, + }; +} + +function writeMachine(state: MachineState): void { + mkdirSync(state.machineDir, { recursive: true }); + writeFileSync(join(state.machineDir, "machine.json"), JSON.stringify(state, null, "\t") + "\n"); +} + +beforeEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.QUICKCHR_DATA_DIR = TEST_DIR; +}); + +afterEach(() => { + if (originalDataDir === undefined) { + delete process.env.QUICKCHR_DATA_DIR; + } else { + process.env.QUICKCHR_DATA_DIR = originalDataDir; + } + rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +describe("descriptor() — v1 shape", () => { + test("descriptorVersion + quickchr.packageVersion present, no apiVersion", async () => { + writeMachine(baseState("router")); + const instance = QuickCHR.get("router"); + expect(instance).not.toBeNull(); + const descriptor = await instance?.descriptor(); + expect(descriptor?.descriptorVersion).toBe(QUICKCHR_DESCRIPTOR_VERSION); + expect(descriptor?.quickchr).toEqual({ packageVersion: packageJson.version }); + // apiVersion would live under quickchr (not the descriptor root) if it were ever + // reintroduced — assert at that nesting level, not just the root. + expect(descriptor?.quickchr).not.toHaveProperty("apiVersion"); + expect(descriptor?.status).toBe("running"); + }); + + test("survives an injected unknown field (additive-only forward-compat)", async () => { + writeMachine(baseState("router")); + const descriptor = await QuickCHR.get("router")?.descriptor(); + const roundTripped = JSON.parse(JSON.stringify({ ...descriptor, futureField: "centrs-can-ignore-this" })); + expect(roundTripped.futureField).toBe("centrs-can-ignore-this"); + expect(roundTripped.descriptorVersion).toBe(QUICKCHR_DESCRIPTOR_VERSION); + expect(roundTripped.services["rest-api"].available).toBe(true); + }); + + test("rest-api and native-api prefer the secure port and report tls correctly", async () => { + writeMachine(baseState("router")); + const descriptor = await QuickCHR.get("router")?.descriptor(); + const restApi = descriptor?.services["rest-api"]; + const nativeApi = descriptor?.services["native-api"]; + expect(restApi?.available).toBe(true); + if (restApi?.available) { + expect(restApi.tls).toBe(true); + expect(restApi.port).toBe(19101); + expect(restApi.transport).toBe("tcp"); + expect(restApi.url).toBe("https://127.0.0.1:19101/rest"); + expect(restApi.auth?.username).toBe("quickchr"); + } + expect(nativeApi?.available).toBe(true); + if (nativeApi?.available) { + expect(nativeApi.tls).toBe(true); + expect(nativeApi.port).toBe(19104); + expect(nativeApi.url).toBe("tls://127.0.0.1:19104"); + } + }); + + test("ssh service reports host/port/transport with tls:false", async () => { + writeMachine(baseState("router")); + const descriptor = await QuickCHR.get("router")?.descriptor(); + const ssh = descriptor?.services.ssh; + expect(ssh?.available).toBe(true); + if (ssh?.available) { + expect(ssh.tls).toBe(false); + expect(ssh.port).toBe(19102); + expect(ssh.host).toBe("127.0.0.1"); + } + }); + + test("http excluded, https kept: rest-api falls back to the secure port (excludePorts fix)", async () => { + const state = baseState("router"); + delete (state.ports as Record).http; + state.excludePorts = ["http"]; + writeMachine(state); + const descriptor = await QuickCHR.get("router")?.descriptor(); + const restApi = descriptor?.services["rest-api"]; + expect(restApi?.available).toBe(true); + if (restApi?.available) { + expect(restApi.tls).toBe(true); + expect(restApi.port).toBe(19101); + } + }); + + test("both rest-api ports excluded: available:false with a stable reason", async () => { + const state = baseState("router"); + delete (state.ports as Record).http; + delete (state.ports as Record).https; + state.excludePorts = ["http", "https"]; + writeMachine(state); + const descriptor = await QuickCHR.get("router")?.descriptor(); + const restApi = descriptor?.services["rest-api"]; + expect(restApi?.available).toBe(false); + if (!restApi?.available) { + expect(restApi?.unavailableReason).toBe("no forwarded port for rest-api"); + } + }); + + test("disableAdmin with no provisioned user: rest-api/native-api available:false", async () => { + const state = baseState("router", { disableAdmin: true, user: undefined }); + writeMachine(state); + const descriptor = await QuickCHR.get("router")?.descriptor(); + const restApi = descriptor?.services["rest-api"]; + const nativeApi = descriptor?.services["native-api"]; + expect(restApi?.available).toBe(false); + if (!restApi?.available) expect(restApi?.unavailableReason).toBe("admin disabled, no user provisioned"); + expect(nativeApi?.available).toBe(false); + // SSH availability is about the forwarded port, not credentials — the port + // still exists, so ssh itself stays available even though passwordAvailable + // should now be false. + const ssh = descriptor?.services.ssh; + expect(ssh?.available).toBe(true); + if (ssh?.available) expect(ssh.auth.passwordAvailable).toBe(false); + }); + + test("disableAdmin with a provisioned user: rest-api/native-api stay available", async () => { + const state = baseState("router", { disableAdmin: true, user: { name: "quickchr", password: "secret" } }); + writeMachine(state); + const descriptor = await QuickCHR.get("router")?.descriptor(); + expect(descriptor?.services["rest-api"].available).toBe(true); + expect(descriptor?.services["native-api"].available).toBe(true); + }); + + test("unresolvable STORED_IN_SECRETS_PASSWORD sentinel: available:false, no leaked sentinel password", async () => { + // state.user.password is the sentinel but the per-instance credential store has + // no matching entry (deleted/corrupted out-of-band) — resolveCreds() would + // otherwise fall through to returning the literal sentinel string as "password". + const state = baseState("router", { user: { name: "quickchr", password: STORED_IN_SECRETS_PASSWORD } }); + writeMachine(state); + const descriptor = await QuickCHR.get("router")?.descriptor(); + expect(descriptor?.services["rest-api"].available).toBe(false); + expect(descriptor?.services["native-api"].available).toBe(false); + const ssh = descriptor?.services.ssh; + expect(ssh?.available).toBe(true); + if (ssh?.available) expect(ssh.auth.passwordAvailable).toBe(false); + }); + + test("ssh: verified managed key advertises private-key batch auth", async () => { + const state = baseState("router", { + managedSshKey: { + privateKeyPath: "/tmp/fake/id_ed25519", + algorithm: "ed25519", + batchVerified: true, + verifiedAt: "2026-01-01T00:02:00.000Z", + }, + }); + writeMachine(state); + const descriptor = await QuickCHR.get("router")?.descriptor(); + const ssh = descriptor?.services.ssh; + expect(ssh?.available).toBe(true); + if (ssh?.available) { + expect(ssh.auth.batchModes).toEqual(["private-key"]); + expect(ssh.auth.privateKeyPath).toBe("/tmp/fake/id_ed25519"); + expect(ssh.auth.modes).toContain("private-key"); + expect(ssh.auth.username).toBe("quickchr"); + } + }); + + test("ssh: unverified managed key never advertises batch private-key auth", async () => { + const state = baseState("router", { + managedSshKey: { + privateKeyPath: "/tmp/fake/id_ed25519", + algorithm: "ed25519", + batchVerified: false, + }, + }); + writeMachine(state); + const descriptor = await QuickCHR.get("router")?.descriptor(); + const ssh = descriptor?.services.ssh; + expect(ssh?.available).toBe(true); + if (ssh?.available) { + expect(ssh.auth.batchModes).toEqual([]); + expect(ssh.auth.privateKeyPath).toBeUndefined(); + expect(ssh.auth.modes).toContain("password"); + } + }); + + test("ssh: absent managed key (e.g. --no-secure-login) reports no batch modes", async () => { + const state = baseState("router", { managedSshKey: undefined }); + writeMachine(state); + const descriptor = await QuickCHR.get("router")?.descriptor(); + const ssh = descriptor?.services.ssh; + expect(ssh?.available).toBe(true); + if (ssh?.available) { + expect(ssh.auth.batchModes).toEqual([]); + expect(ssh.auth.privateKeyPath).toBeUndefined(); + expect(ssh.auth.modes).not.toContain("private-key"); + } + }); + + test("ssh port excluded: available:false", async () => { + const state = baseState("router"); + delete (state.ports as Record).ssh; + state.excludePorts = ["ssh"]; + writeMachine(state); + const descriptor = await QuickCHR.get("router")?.descriptor(); + const ssh = descriptor?.services.ssh; + expect(ssh?.available).toBe(false); + if (!ssh?.available) expect(ssh?.unavailableReason).toBe("no forwarded port for ssh"); + }); + + test("customForwards lists winbox and extra forwards, omitted when empty", async () => { + const withExtra = baseState("router"); + withExtra.ports.snmp = { name: "snmp", host: 19110, guest: 161, proto: "udp" }; + writeMachine(withExtra); + const descriptor = await QuickCHR.get("router")?.descriptor(); + expect(descriptor?.customForwards).toEqual( + expect.arrayContaining([ + { name: "winbox", transport: "tcp", host: "127.0.0.1", hostPort: 19105, guestPort: 8291 }, + { name: "snmp", transport: "udp", host: "127.0.0.1", hostPort: 19110, guestPort: 161 }, + ]), + ); + }); + + test("customForwards omitted when only the three canonical services are forwarded", async () => { + const state = baseState("router"); + delete (state.ports as Record).winbox; + state.excludePorts = ["winbox"]; + writeMachine(state); + const descriptor = await QuickCHR.get("router")?.descriptor(); + expect(descriptor?.customForwards).toBeUndefined(); + }); + + test("networks mirrors state.networks[].specifier topology", async () => { + const state = baseState("router", { + networks: [ + { specifier: "user", id: "net0" }, + { specifier: { type: "bridged", iface: "en0" }, id: "net1" }, + ], + }); + writeMachine(state); + const descriptor = await QuickCHR.get("router")?.descriptor(); + expect(descriptor?.networks).toEqual([ + { id: "net0", specifier: "user" }, + { id: "net1", specifier: { type: "bridged", iface: "en0" } }, + ]); + }); + + test("stopped machine still throws MACHINE_STOPPED", async () => { + writeMachine(baseState("stopped-router", { status: "stopped", pid: undefined, lastStartedAt: undefined })); + const instance = QuickCHR.get("stopped-router"); + expect(instance).not.toBeNull(); + await expect(instance?.descriptor()).rejects.toMatchObject({ code: "MACHINE_STOPPED" }); + }); +});