@tikoci/quickchr 0.4.3 → 0.4.5
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 +6 -5
- package/MANUAL.md +26 -14
- package/README.md +19 -9
- package/package.json +1 -1
- package/src/cli/index.ts +20 -6
- package/src/index.ts +6 -2
- package/src/lib/provision.ts +255 -33
- package/src/lib/quickchr.ts +160 -32
- package/src/lib/types.ts +147 -22
package/DESIGN.md
CHANGED
|
@@ -154,10 +154,11 @@ with a layered design:
|
|
|
154
154
|
dispatches. `test-filter` narrows; `run-examples` defaults ON. Each runner boots its
|
|
155
155
|
native CHR arch — `detectAccel()` picks KVM/HVF/TCG. Agents dispatch this to ground
|
|
156
156
|
platform hypotheses without waiting for a PR cycle.
|
|
157
|
-
- **`release.yml`** — one-click release: freshness gate
|
|
158
|
-
continuously release-able) +
|
|
159
|
-
|
|
160
|
-
--provenance` (odd minor → `next`, even → `latest`).
|
|
157
|
+
- **`release.yml`** — one-click publish from committed release state: freshness gate
|
|
158
|
+
(no suite re-run — main is kept continuously release-able) + `package.json` version
|
|
159
|
+
with a matching non-empty CHANGELOG section → tag + GitHub Release + `npm publish
|
|
160
|
+
--provenance` (odd minor → `next`, even → `latest`). CI never bumps versions or pushes
|
|
161
|
+
to protected `main`.
|
|
161
162
|
- **`ros-versions.yml`** — daily: new RouterOS versions (per channel) with no linux-x86
|
|
162
163
|
record in `ci-data/tested-versions.json` ride the `routeros-targets` matrix of a
|
|
163
164
|
single integration dispatch.
|
|
@@ -336,7 +337,7 @@ the CLI/library surface is tracked in
|
|
|
336
337
|
|
|
337
338
|
### Agent-Friendliness — Discoverability Over Features
|
|
338
339
|
|
|
339
|
-
A recurring lesson from downstream agents (donny, centrs, restraml): the friction is usually **finding** an existing capability, not a missing one. Issue #18 (centrs) nearly rebuilt UDP forwarding, `socket-connect` L2, and the guest→host gateway — all of which already existed — because the only way to choose among them was reading `src/lib/network.ts`. Rule: **every CLI-documented capability also needs a library-facing, by-goal surface** — JSDoc at the call site, a by-goal recipe (`docs/networking-recipes.md`), and coverage in the `routeros-quickchr` skill — or agents won't find it. Connection handoff for harnesses goes through `ChrInstance.descriptor()` / `quickchr inspect` / `quickchr env` (credential-bearing by design), never by reading `machine.json`.
|
|
340
|
+
A recurring lesson from downstream agents (donny, centrs, restraml): the friction is usually **finding** an existing capability, not a missing one. Issue #18 (centrs) nearly rebuilt UDP forwarding, `socket-connect` L2, and the guest→host gateway — all of which already existed — because the only way to choose among them was reading `src/lib/network.ts`. Rule: **every CLI-documented capability also needs a library-facing, by-goal surface** — JSDoc at the call site, a by-goal recipe (`docs/networking-recipes.md`), and coverage in the `routeros-quickchr` skill — or agents won't find it. Connection handoff for harnesses goes through `ChrInstance.descriptor()` / `quickchr inspect` / `quickchr env` (credential-bearing by design), never by reading `machine.json`. The structured, versioned **descriptor v1** contract that centrs consumes for `--quickchr` targets — its shape, per-service semantics, scope boundaries, and staged implementation plan — is specified in [`docs/centrs-interface.md`](./docs/centrs-interface.md) (issue [#71](https://github.com/tikoci/quickchr/issues/71)).
|
|
340
341
|
|
|
341
342
|
### Out of Scope (decided)
|
|
342
343
|
|
package/MANUAL.md
CHANGED
|
@@ -218,10 +218,16 @@ Live REST query. Hits the running CHR and returns the requested group
|
|
|
218
218
|
|
|
219
219
|
#### `inspect <name> [--json]`
|
|
220
220
|
|
|
221
|
-
Stable
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
221
|
+
Stable, versioned (`descriptorVersion: 1`) connection descriptor for a
|
|
222
|
+
**running** CHR — the quickchr ↔ centrs interface contract (issue #71,
|
|
223
|
+
[`docs/centrs-interface.md`](docs/centrs-interface.md)). Machine identity
|
|
224
|
+
(name, version, arch, cpu, mem, pid, machineDir, timestamps) plus a
|
|
225
|
+
per-service `services` map (`rest-api`, `native-api`, `ssh`), each entry
|
|
226
|
+
giving its host, port, transport, `tls` flag, availability, and auth; plus
|
|
227
|
+
optional `customForwards` (e.g. `winbox`) and topology-only `networks`.
|
|
228
|
+
`--json` is accepted for symmetry with other read commands; output is
|
|
229
|
+
always JSON. Does not include subprocess env vars — use `quickchr env` for
|
|
230
|
+
those.
|
|
225
231
|
|
|
226
232
|
Stopped machines fail with `MACHINE_STOPPED` instead of returning stale
|
|
227
233
|
connection details. Use `quickchr start <name>` first.
|
|
@@ -523,7 +529,7 @@ interface ChrInstance {
|
|
|
523
529
|
|
|
524
530
|
queryLoad(): Promise<ChrLoadSample | null>;
|
|
525
531
|
subprocessEnv(): Promise<Record<string, string>>;
|
|
526
|
-
descriptor(): Promise<
|
|
532
|
+
descriptor(): Promise<Descriptor>;
|
|
527
533
|
}
|
|
528
534
|
```
|
|
529
535
|
|
|
@@ -536,15 +542,21 @@ transiently resets connections post-boot/reboot). HTTP errors and
|
|
|
536
542
|
`QUICKCHR_AUTH`, plus legacy `URLBASE`/`BASICAUTH`) so child processes
|
|
537
543
|
can hit the CHR without re-resolving auth.
|
|
538
544
|
|
|
539
|
-
`descriptor()` returns the stable JSON shape used by `quickchr
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
`
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
`
|
|
547
|
-
|
|
545
|
+
`descriptor()` returns the stable, versioned JSON shape used by `quickchr
|
|
546
|
+
inspect` — the quickchr ↔ centrs interface contract (issue #71,
|
|
547
|
+
[`docs/centrs-interface.md`](docs/centrs-interface.md)): machine identity,
|
|
548
|
+
status, a per-service `services` map (`rest-api`, `native-api`, `ssh` — each
|
|
549
|
+
with host/port/transport/`tls`/availability/auth), optional
|
|
550
|
+
`customForwards`/`networks`, and timestamps. It is intentionally
|
|
551
|
+
**running-only** and throws `MACHINE_STOPPED` for stopped machines. It has
|
|
552
|
+
no `env` field — use `subprocessEnv()` for env-var-shaped connection facts.
|
|
553
|
+
|
|
554
|
+
Both `subprocessEnv()` and `descriptor()` expose auth material by design —
|
|
555
|
+
`subprocessEnv()`'s `QUICKCHR_AUTH`/`BASICAUTH` env vars, and `descriptor()`'s
|
|
556
|
+
own per-service auth fields (`services["rest-api"].auth.password`/`.basic`/
|
|
557
|
+
`.header`; `services["native-api"].auth.password`, no basic/header;
|
|
558
|
+
`services.ssh.auth.privateKeyPath`). Treat their output as credentials and
|
|
559
|
+
redact before logging or attaching to bug reports.
|
|
548
560
|
|
|
549
561
|
### `StartOptions` (selected)
|
|
550
562
|
|
package/README.md
CHANGED
|
@@ -302,20 +302,30 @@ host port for the machine. Existing machines keep the mapping stored in
|
|
|
302
302
|
|
|
303
303
|
### Machine Descriptors and Subprocess Environments
|
|
304
304
|
|
|
305
|
-
`quickchr inspect <name> [--json]` emits a stable
|
|
306
|
-
|
|
307
|
-
|
|
305
|
+
`quickchr inspect <name> [--json]` emits a stable, versioned
|
|
306
|
+
(`descriptorVersion: 1`) JSON descriptor for a **running** machine — the
|
|
307
|
+
quickchr ↔ centrs interface contract
|
|
308
|
+
([issue #71](https://github.com/tikoci/quickchr/issues/71),
|
|
309
|
+
[`docs/centrs-interface.md`](docs/centrs-interface.md)): status, machine
|
|
310
|
+
identity, and a per-service `services` map (`rest-api`, `native-api`, `ssh`)
|
|
311
|
+
giving host/port/transport/`tls`/availability/auth for each, plus optional
|
|
312
|
+
`customForwards` and `networks`. `--json` is accepted for parity; inspect
|
|
313
|
+
output is always JSON. It has no `env` field — use `quickchr env` for
|
|
314
|
+
subprocess env vars.
|
|
308
315
|
|
|
309
316
|
`quickchr env <name> [--json]` prints the same subprocess environment as
|
|
310
317
|
`ChrInstance.subprocessEnv()`: shell `KEY=value` lines by default, or a JSON
|
|
311
318
|
map with `--json`.
|
|
312
319
|
|
|
313
|
-
> **Credential caveat:** descriptor/env output includes connection secrets
|
|
314
|
-
> (
|
|
315
|
-
>
|
|
316
|
-
>
|
|
317
|
-
>
|
|
318
|
-
>
|
|
320
|
+
> **Credential caveat:** descriptor/env output includes connection secrets —
|
|
321
|
+
> `subprocessEnv()`/`quickchr env`'s `QUICKCHR_AUTH`/`BASICAUTH`, and each
|
|
322
|
+
> `descriptor()` service's own auth fields (`services["rest-api"].auth.password`/
|
|
323
|
+
> `.basic`/`.header`; `services["native-api"].auth.password`, no basic/header;
|
|
324
|
+
> `services.ssh.auth.privateKeyPath`) — so child processes can connect without
|
|
325
|
+
> reading quickchr's secret store. Treat it like a password: do not commit it,
|
|
326
|
+
> paste it into public issues, or leave it in CI logs. Stopped machines fail
|
|
327
|
+
> with `MACHINE_STOPPED`; start the machine before requesting a descriptor or
|
|
328
|
+
> env map.
|
|
319
329
|
|
|
320
330
|
### Library Usage
|
|
321
331
|
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -1490,7 +1490,7 @@ function shellQuoteEnv(value: string): string {
|
|
|
1490
1490
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
1491
1491
|
}
|
|
1492
1492
|
|
|
1493
|
-
async function
|
|
1493
|
+
async function getRunningInstance(name: string) {
|
|
1494
1494
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1495
1495
|
const { machineNotFoundMessage } = await import("./format.ts");
|
|
1496
1496
|
const instance = QuickCHR.get(name);
|
|
@@ -1498,6 +1498,18 @@ async function getRunningDescriptor(name: string) {
|
|
|
1498
1498
|
console.error(machineNotFoundMessage(name));
|
|
1499
1499
|
process.exit(1);
|
|
1500
1500
|
}
|
|
1501
|
+
if (instance.state.status !== "running") {
|
|
1502
|
+
const { QuickCHRError } = await import("../lib/types.ts");
|
|
1503
|
+
throw new QuickCHRError(
|
|
1504
|
+
"MACHINE_STOPPED",
|
|
1505
|
+
`Machine "${name}" must be running to inspect its connection environment (current status: ${instance.state.status}).`,
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
return instance;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
async function getRunningDescriptor(name: string) {
|
|
1512
|
+
const instance = await getRunningInstance(name);
|
|
1501
1513
|
return instance.descriptor();
|
|
1502
1514
|
}
|
|
1503
1515
|
|
|
@@ -1520,12 +1532,13 @@ async function cmdEnv(argv: string[] = []) {
|
|
|
1520
1532
|
console.error("Usage: quickchr env <name> [--json]");
|
|
1521
1533
|
process.exit(1);
|
|
1522
1534
|
}
|
|
1523
|
-
const
|
|
1535
|
+
const instance = await getRunningInstance(name);
|
|
1536
|
+
const env = await instance.subprocessEnv();
|
|
1524
1537
|
if (asJson) {
|
|
1525
|
-
console.log(JSON.stringify(
|
|
1538
|
+
console.log(JSON.stringify(env, null, 2));
|
|
1526
1539
|
return;
|
|
1527
1540
|
}
|
|
1528
|
-
for (const [key, value] of Object.entries(
|
|
1541
|
+
for (const [key, value] of Object.entries(env)) {
|
|
1529
1542
|
console.log(`${key}=${shellQuoteEnv(value)}`);
|
|
1530
1543
|
}
|
|
1531
1544
|
}
|
|
@@ -2846,8 +2859,9 @@ List all CHR instances or show detailed info for one.
|
|
|
2846
2859
|
case "inspect":
|
|
2847
2860
|
console.log(`quickchr inspect <name> [--json]
|
|
2848
2861
|
|
|
2849
|
-
Print a stable JSON descriptor for a running CHR instance:
|
|
2850
|
-
|
|
2862
|
+
Print a stable JSON descriptor for a running CHR instance: status, machine
|
|
2863
|
+
identity, and per-service connection facts (rest-api, native-api, ssh).
|
|
2864
|
+
Use 'quickchr env' for subprocess env vars.
|
|
2851
2865
|
|
|
2852
2866
|
<name> Name of a running CHR instance.
|
|
2853
2867
|
--json Accepted for parity; output is always JSON.`);
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,8 @@ export type {
|
|
|
32
32
|
Channel,
|
|
33
33
|
ChrInstance,
|
|
34
34
|
ChrPorts,
|
|
35
|
+
CustomForward,
|
|
36
|
+
Descriptor,
|
|
35
37
|
DeviceModeOptions,
|
|
36
38
|
DoctorCheck,
|
|
37
39
|
DoctorResult,
|
|
@@ -44,11 +46,11 @@ export type {
|
|
|
44
46
|
LicenseLevel,
|
|
45
47
|
LicenseOptions,
|
|
46
48
|
MachineConfig,
|
|
47
|
-
MachineDescriptor,
|
|
48
49
|
MachineState,
|
|
49
50
|
NetworkConfig,
|
|
50
51
|
NetworkMode,
|
|
51
52
|
NetworkSpecifier,
|
|
53
|
+
NetworkTopologyEntry,
|
|
52
54
|
PackageManager,
|
|
53
55
|
PlatformInfo,
|
|
54
56
|
PortMapping,
|
|
@@ -59,12 +61,14 @@ export type {
|
|
|
59
61
|
QgaNetworkIpAddress,
|
|
60
62
|
QgaOsInfo,
|
|
61
63
|
QgaTimezone,
|
|
64
|
+
ServiceEndpoint,
|
|
62
65
|
ServiceName,
|
|
63
66
|
SnapshotInfo,
|
|
67
|
+
SshServiceEndpoint,
|
|
64
68
|
StartOptions,
|
|
65
69
|
} from "./lib/types.ts";
|
|
66
70
|
|
|
67
|
-
export { QuickCHRError } from "./lib/types.ts";
|
|
71
|
+
export { QuickCHRError, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./lib/types.ts";
|
|
68
72
|
export type { ErrorCode, HostInterface } from "./lib/types.ts";
|
|
69
73
|
|
|
70
74
|
// Interface detection
|
package/src/lib/provision.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Post-boot provisioning — user creation, admin disable via REST API,
|
|
3
3
|
* with a console transport fallback when REST is unavailable.
|
|
4
|
+
* cspell:ignore NUL
|
|
4
5
|
*/
|
|
5
6
|
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
6
8
|
import { mkdirSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
import { QuickCHRError } from "./types.ts";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { QuickCHRError, type ManagedSshKey } from "./types.ts";
|
|
9
11
|
import { saveInstanceCredentials } from "./credentials.ts";
|
|
10
12
|
import { generatePassword } from "./password.ts";
|
|
11
13
|
import { waitForBoot } from "./qemu.ts";
|
|
@@ -19,6 +21,9 @@ export const QUICKCHR_USER = "quickchr";
|
|
|
19
21
|
export interface ProvisionResult {
|
|
20
22
|
/** The user that was created (or null if none). */
|
|
21
23
|
user: { name: string; password: string } | null;
|
|
24
|
+
/** The managed SSH key installed for that user, if provisioning installed one
|
|
25
|
+
* (issue #74). Absent when no key was installed. */
|
|
26
|
+
managedSshKey?: ManagedSshKey;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
async function readUser(httpPort: number, auth: string, name: string): Promise<Record<string, unknown> | undefined> {
|
|
@@ -261,35 +266,230 @@ async function consoleProvision(
|
|
|
261
266
|
}
|
|
262
267
|
|
|
263
268
|
/**
|
|
264
|
-
*
|
|
269
|
+
* Console markers that mean RouterOS rejected a `/user/ssh-keys/add`. RouterOS
|
|
270
|
+
* reports rejection inline (no status code) — these are the parser/command error
|
|
271
|
+
* prefixes it emits. Kept deliberately specific (not bare "expected"/"invalid",
|
|
272
|
+
* which appear in benign output) so it doesn't false-positive. Exported so the
|
|
273
|
+
* ssh-keys lab runner classifies rejections identically instead of duplicating it.
|
|
274
|
+
*/
|
|
275
|
+
export const SSH_KEY_REJECTION_PATTERN = /failure:|syntax error|no such item|bad command name|expected end of/i;
|
|
276
|
+
|
|
277
|
+
/** Managed-key algorithm quickchr generates. Grounded as accepted across the
|
|
278
|
+
* provisioning floor (7.20.8) and current stable in REPORT.md — issue #74. */
|
|
279
|
+
const MANAGED_SSH_KEY_ALGORITHM = "ed25519";
|
|
280
|
+
export const SSH_NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
281
|
+
|
|
282
|
+
/** OpenSSH's `-F <config>` needs a file it can actually open — Win32-OpenSSH's
|
|
283
|
+
* config-file loader rejects the null device itself ("Can't open user config
|
|
284
|
+
* file NUL: No such file or directory", exit 255; grounded on windows-latest
|
|
285
|
+
* OpenSSH_10.3p1 in issue #87), unlike POSIX where /dev/null opens fine as an
|
|
286
|
+
* empty config. A real empty file on disk is the config-suppression trick
|
|
287
|
+
* that works identically on every platform, so `-F` always gets one of these
|
|
288
|
+
* instead of {@link SSH_NULL_DEVICE}. */
|
|
289
|
+
export async function ensureEmptySshConfig(dir: string): Promise<string> {
|
|
290
|
+
const path = join(dir, "empty_ssh_config");
|
|
291
|
+
await Bun.write(path, "");
|
|
292
|
+
return path;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export type SshKeyListRow = {
|
|
296
|
+
user?: string;
|
|
297
|
+
info?: string;
|
|
298
|
+
"key-owner"?: string;
|
|
299
|
+
"key-type"?: string;
|
|
300
|
+
fingerprint?: string;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
function sshKeyOwner(row: SshKeyListRow): string {
|
|
304
|
+
return row.info ?? row["key-owner"] ?? "";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function opensshSha256Fingerprint(publicKey: string): string | undefined {
|
|
308
|
+
const keyBlob = publicKey.trim().split(/\s+/)[1];
|
|
309
|
+
if (!keyBlob) return undefined;
|
|
310
|
+
try {
|
|
311
|
+
const digest = createHash("sha256").update(Buffer.from(keyBlob, "base64")).digest("base64").replace(/=+$/, "");
|
|
312
|
+
return `SHA256:${digest}`;
|
|
313
|
+
} catch {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function normalizeSshFingerprint(fingerprint: string): string {
|
|
319
|
+
return fingerprint.replace(/=+$/, "");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function matchesManagedSshKey(row: SshKeyListRow, username: string, keyComment: string, fingerprint?: string): boolean {
|
|
323
|
+
if (row.user !== username) return false;
|
|
324
|
+
if (sshKeyOwner(row) !== keyComment) return false;
|
|
325
|
+
if (row["key-type"] && row["key-type"] !== MANAGED_SSH_KEY_ALGORITHM) return false;
|
|
326
|
+
if (fingerprint && row.fingerprint && normalizeSshFingerprint(row.fingerprint) !== normalizeSshFingerprint(fingerprint)) return false;
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const SSH_KEY_LIST_BUDGET_MS = 30_000;
|
|
331
|
+
|
|
332
|
+
export interface SshKeyListingVerification {
|
|
333
|
+
listed: boolean;
|
|
334
|
+
attempts: number;
|
|
335
|
+
elapsedMs: number;
|
|
336
|
+
lastDiagnostic: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Wait for RouterOS to expose the generated key through `/user/ssh-keys`.
|
|
340
|
+
* A request receives the full remaining convergence budget because the first
|
|
341
|
+
* read of this endpoint can itself take many seconds under TCG. */
|
|
342
|
+
export async function waitForManagedSshKeyListing(
|
|
343
|
+
httpPort: number,
|
|
344
|
+
auth: string,
|
|
345
|
+
username: string,
|
|
346
|
+
keyComment: string,
|
|
347
|
+
fingerprint: string,
|
|
348
|
+
timeoutMs = SSH_KEY_LIST_BUDGET_MS,
|
|
349
|
+
): Promise<SshKeyListingVerification> {
|
|
350
|
+
const startedAt = Date.now();
|
|
351
|
+
const deadline = startedAt + timeoutMs;
|
|
352
|
+
let attempts = 0;
|
|
353
|
+
let lastDiagnostic = "no REST attempt completed";
|
|
354
|
+
|
|
355
|
+
while (Date.now() < deadline) {
|
|
356
|
+
attempts++;
|
|
357
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
358
|
+
try {
|
|
359
|
+
const { status, body } = await restGet(
|
|
360
|
+
`http://127.0.0.1:${httpPort}/rest/user/ssh-keys`,
|
|
361
|
+
auth,
|
|
362
|
+
remainingMs,
|
|
363
|
+
);
|
|
364
|
+
if (status >= 200 && status < 300) {
|
|
365
|
+
let keys: SshKeyListRow[] | undefined;
|
|
366
|
+
try {
|
|
367
|
+
keys = JSON.parse(body) as SshKeyListRow[];
|
|
368
|
+
} catch (parseErr) {
|
|
369
|
+
lastDiagnostic = `HTTP ${status}: non-JSON body (${String(parseErr)}): ${body}`;
|
|
370
|
+
}
|
|
371
|
+
if (Array.isArray(keys)) {
|
|
372
|
+
if (keys.some((key) => matchesManagedSshKey(key, username, keyComment, fingerprint))) {
|
|
373
|
+
return { listed: true, attempts, elapsedMs: Date.now() - startedAt, lastDiagnostic: body };
|
|
374
|
+
}
|
|
375
|
+
lastDiagnostic = `HTTP ${status}: no matching key in ${keys.length} row(s)`;
|
|
376
|
+
} else if (keys !== undefined) {
|
|
377
|
+
lastDiagnostic = `HTTP ${status}: expected an array, received ${body}`;
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
lastDiagnostic = `HTTP ${status}: ${body}`;
|
|
381
|
+
}
|
|
382
|
+
} catch (e) {
|
|
383
|
+
lastDiagnostic = String(e);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const sleepMs = Math.min(500, Math.max(0, deadline - Date.now()));
|
|
387
|
+
if (sleepMs > 0) await Bun.sleep(sleepMs);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { listed: false, attempts, elapsedMs: Date.now() - startedAt, lastDiagnostic };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Cap on the diagnostic text surfaced from a failed batch-login probe — enough
|
|
394
|
+
* to see the ssh client's actual error, not so much a noisy client fills logs. */
|
|
395
|
+
const BATCH_LOGIN_DIAGNOSTIC_LIMIT = 2000;
|
|
396
|
+
|
|
397
|
+
interface BatchLoginResult {
|
|
398
|
+
verified: boolean;
|
|
399
|
+
/** ssh's combined stdout+stderr (or the spawn error), truncated. Always
|
|
400
|
+
* populated so a failure is diagnosable instead of collapsing to a bool
|
|
401
|
+
* (issue #87 — this was the missing piece on the first Windows CI run). */
|
|
402
|
+
diagnostic: string;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Attempt a real host-OpenSSH batch login with the managed private key — the
|
|
406
|
+
* exact mode (`BatchMode=yes`, `PasswordAuthentication=no`, `IdentitiesOnly=yes`,
|
|
407
|
+
* ignoring ssh_config) that centrs and the #71 descriptor need. Verified only
|
|
408
|
+
* on a clean passwordless login. Best-effort: never throws, and is killed after
|
|
409
|
+
* 15s rather than hanging provisioning. */
|
|
410
|
+
async function verifyBatchLogin(sshPort: number, username: string, privateKeyPath: string): Promise<BatchLoginResult> {
|
|
411
|
+
// No SSH port forwarded (or not passed in) → can't prove batch auth from the host.
|
|
412
|
+
if (!sshPort || sshPort <= 0) return { verified: false, diagnostic: "no SSH port forwarded" };
|
|
413
|
+
try {
|
|
414
|
+
const emptyConfig = await ensureEmptySshConfig(dirname(privateKeyPath));
|
|
415
|
+
const proc = Bun.spawn(
|
|
416
|
+
[
|
|
417
|
+
"ssh",
|
|
418
|
+
"-F", emptyConfig,
|
|
419
|
+
"-o", "StrictHostKeyChecking=no",
|
|
420
|
+
"-o", `UserKnownHostsFile=${SSH_NULL_DEVICE}`,
|
|
421
|
+
"-o", "PasswordAuthentication=no",
|
|
422
|
+
"-o", "IdentitiesOnly=yes",
|
|
423
|
+
"-o", "BatchMode=yes",
|
|
424
|
+
"-o", "ConnectTimeout=10",
|
|
425
|
+
"-i", privateKeyPath,
|
|
426
|
+
`${username}@127.0.0.1`, "-p", String(sshPort),
|
|
427
|
+
':put "quickchr-ssh-batch-ok"',
|
|
428
|
+
],
|
|
429
|
+
{ stdout: "pipe", stderr: "pipe", stdin: "ignore" },
|
|
430
|
+
);
|
|
431
|
+
const timer = setTimeout(() => { try { proc.kill(); } catch { /* already gone */ } }, 15_000);
|
|
432
|
+
try {
|
|
433
|
+
const [out, err] = await Promise.all([
|
|
434
|
+
new Response(proc.stdout).text(),
|
|
435
|
+
new Response(proc.stderr).text(),
|
|
436
|
+
]);
|
|
437
|
+
const exitCode = await proc.exited;
|
|
438
|
+
const combined = (out + err).trim();
|
|
439
|
+
const verified = combined.includes("quickchr-ssh-batch-ok");
|
|
440
|
+
const diagnostic = verified
|
|
441
|
+
? "ok"
|
|
442
|
+
: `exit ${exitCode}: ${(combined || "<no output>").slice(0, BATCH_LOGIN_DIAGNOSTIC_LIMIT)}`;
|
|
443
|
+
return { verified, diagnostic };
|
|
444
|
+
} finally {
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
}
|
|
447
|
+
} catch (e) {
|
|
448
|
+
return { verified: false, diagnostic: `ssh spawn failed: ${String(e)}` };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Generate an ed25519 SSH keypair and install the public key on the CHR for `username`,
|
|
454
|
+
* then verify it works for a host-OpenSSH batch login.
|
|
265
455
|
* Keys are stored in `<machineDir>/ssh/id_ed25519` (private) and `.pub` (public).
|
|
266
456
|
* This enables SSH transport without passwords for the quickchr managed user.
|
|
457
|
+
*
|
|
458
|
+
* Returns the {@link ManagedSshKey} fact (key path, algorithm, and whether a real
|
|
459
|
+
* `BatchMode=yes` login succeeded) so callers can persist it for the #71 descriptor.
|
|
267
460
|
*/
|
|
268
461
|
export async function installSshKey(
|
|
269
462
|
httpPort: number,
|
|
463
|
+
sshPort: number,
|
|
270
464
|
username: string,
|
|
271
465
|
machineName: string,
|
|
272
466
|
machineDir: string,
|
|
273
467
|
portBase?: number,
|
|
274
|
-
|
|
468
|
+
logger?: ProgressLogger,
|
|
469
|
+
): Promise<ManagedSshKey> {
|
|
275
470
|
const sshDir = join(machineDir, "ssh");
|
|
276
471
|
mkdirSync(sshDir, { recursive: true });
|
|
277
472
|
|
|
278
473
|
const privateKeyPath = join(sshDir, "id_ed25519");
|
|
474
|
+
const keyComment = `quickchr@${machineName}`;
|
|
279
475
|
|
|
280
476
|
const keygen = Bun.spawnSync(
|
|
281
|
-
["ssh-keygen", "-t",
|
|
477
|
+
["ssh-keygen", "-t", MANAGED_SSH_KEY_ALGORITHM, "-f", privateKeyPath, "-N", "", "-C", keyComment],
|
|
282
478
|
{ stdout: "pipe", stderr: "pipe" },
|
|
283
479
|
);
|
|
284
480
|
if (keygen.exitCode !== 0) {
|
|
285
|
-
throw new
|
|
481
|
+
throw new QuickCHRError("SPAWN_FAILED", `ssh-keygen failed: ${new TextDecoder().decode(keygen.stderr)}`);
|
|
286
482
|
}
|
|
287
483
|
|
|
288
484
|
const pubKey = (await Bun.file(`${privateKeyPath}.pub`).text()).trim();
|
|
485
|
+
const expectedFingerprint = opensshSha256Fingerprint(pubKey);
|
|
486
|
+
if (!expectedFingerprint) {
|
|
487
|
+
throw new QuickCHRError("PROCESS_FAILED", "Could not compute fingerprint for quickchr-generated SSH public key");
|
|
488
|
+
}
|
|
289
489
|
|
|
290
490
|
// Install via serial console — commits synchronously unlike the REST endpoint
|
|
291
491
|
// which may return 200 OK before the key is durable in RouterOS storage.
|
|
292
|
-
await consoleExec(
|
|
492
|
+
const { output: addOutput } = await consoleExec(
|
|
293
493
|
machineDir,
|
|
294
494
|
`/user/ssh-keys/add user="${username}" key="${pubKey}"`,
|
|
295
495
|
"admin",
|
|
@@ -298,30 +498,50 @@ export async function installSshKey(
|
|
|
298
498
|
portBase,
|
|
299
499
|
);
|
|
300
500
|
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const { status, body: listBody } = await restGet(
|
|
308
|
-
`http://127.0.0.1:${httpPort}/rest/user/ssh-keys`,
|
|
309
|
-
auth,
|
|
310
|
-
5_000,
|
|
311
|
-
);
|
|
312
|
-
if (status >= 200 && status < 300) {
|
|
313
|
-
lastListBody = listBody;
|
|
314
|
-
const keys = JSON.parse(listBody) as Array<{ user?: string }>;
|
|
315
|
-
if (Array.isArray(keys) && keys.some((k) => k.user === username)) return;
|
|
316
|
-
} else {
|
|
317
|
-
lastListBody = `HTTP ${status}`;
|
|
318
|
-
}
|
|
319
|
-
} catch (e) {
|
|
320
|
-
lastListBody = String(e);
|
|
321
|
-
}
|
|
322
|
-
await Bun.sleep(500);
|
|
501
|
+
// RouterOS reports key rejection (e.g. an unsupported algorithm on the running
|
|
502
|
+
// version, or a malformed key) inline on the console rather than by any status
|
|
503
|
+
// code. Surface it immediately so the failure is diagnosable, instead of letting
|
|
504
|
+
// it masquerade as the generic "did not appear in REST listing" timeout below.
|
|
505
|
+
if (SSH_KEY_REJECTION_PATTERN.test(addOutput)) {
|
|
506
|
+
throw new QuickCHRError("EXEC_FAILED", `RouterOS rejected the SSH key for ${username}: ${addOutput.trim()}`);
|
|
323
507
|
}
|
|
324
|
-
|
|
508
|
+
|
|
509
|
+
// Verify the generated key appears in the REST listing. Match the public-key
|
|
510
|
+
// comment (`key-owner` on older 7.x, `info` on newer 7.x) rather than just the
|
|
511
|
+
// user, so an older key for the same user cannot satisfy this check.
|
|
512
|
+
const listResult = await waitForManagedSshKeyListing(
|
|
513
|
+
httpPort,
|
|
514
|
+
`Basic ${btoa("admin:")}`,
|
|
515
|
+
username,
|
|
516
|
+
keyComment,
|
|
517
|
+
expectedFingerprint,
|
|
518
|
+
);
|
|
519
|
+
if (!listResult.listed) {
|
|
520
|
+
throw new QuickCHRError(
|
|
521
|
+
"PROCESS_FAILED",
|
|
522
|
+
`SSH key for ${username} installed via console but did not appear in REST listing within 30s ` +
|
|
523
|
+
`after ${listResult.attempts} attempt(s) and ${listResult.elapsedMs}ms ` +
|
|
524
|
+
`(console output: ${addOutput.trim() || "<empty>"}; last REST attempt: ${listResult.lastDiagnostic})`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
if (listResult.attempts > 1 || listResult.elapsedMs > 5_000) {
|
|
528
|
+
logger?.status(` SSH key listing verified after ${listResult.attempts} attempt(s) in ${listResult.elapsedMs}ms`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Presence in the listing is necessary but not sufficient — prove the key
|
|
532
|
+
// actually authenticates a host-OpenSSH batch client. Non-fatal: an unverified
|
|
533
|
+
// key is still installed, we just record batchVerified=false so the #71
|
|
534
|
+
// descriptor won't advertise batch key auth it can't stand behind.
|
|
535
|
+
const { verified: batchVerified, diagnostic: batchDiagnostic } = await verifyBatchLogin(sshPort, username, privateKeyPath);
|
|
536
|
+
if (!batchVerified) {
|
|
537
|
+
logger?.status(` SSH batch login not verified: ${batchDiagnostic}`);
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
privateKeyPath,
|
|
541
|
+
algorithm: MANAGED_SSH_KEY_ALGORITHM,
|
|
542
|
+
batchVerified,
|
|
543
|
+
verifiedAt: batchVerified ? new Date().toISOString() : undefined,
|
|
544
|
+
};
|
|
325
545
|
}
|
|
326
546
|
|
|
327
547
|
/** Run all provisioning steps based on the config.
|
|
@@ -345,6 +565,7 @@ export async function provision(
|
|
|
345
565
|
logger?: ProgressLogger,
|
|
346
566
|
machineDir?: string,
|
|
347
567
|
portBase?: number,
|
|
568
|
+
sshPort?: number,
|
|
348
569
|
): Promise<ProvisionResult> {
|
|
349
570
|
const log = logger ?? createLogger();
|
|
350
571
|
|
|
@@ -375,7 +596,7 @@ export async function provision(
|
|
|
375
596
|
if (secureLogin && !user && effectiveUser) {
|
|
376
597
|
try {
|
|
377
598
|
await waitForRest(httpPort, 30_000);
|
|
378
|
-
await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
|
|
599
|
+
result.managedSshKey = await installSshKey(httpPort, sshPort ?? 0, effectiveUser.name, machineName, machineDir, portBase, log);
|
|
379
600
|
} catch (keyErr) {
|
|
380
601
|
log.warn(`SSH key install failed after console provisioning (SSH transport will fall back to password): ${keyErr}`);
|
|
381
602
|
}
|
|
@@ -393,12 +614,13 @@ export async function provision(
|
|
|
393
614
|
saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
|
|
394
615
|
}
|
|
395
616
|
|
|
617
|
+
let managedSshKey: ManagedSshKey | undefined;
|
|
396
618
|
if (secureLogin && !user && effectiveUser && machineDir) {
|
|
397
619
|
// Brief pause so RouterOS propagates the new user to all subsystems
|
|
398
620
|
// (including the SSH key store) before we attempt key installation.
|
|
399
621
|
await Bun.sleep(1000);
|
|
400
622
|
try {
|
|
401
|
-
await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
|
|
623
|
+
managedSshKey = await installSshKey(httpPort, sshPort ?? 0, effectiveUser.name, machineName, machineDir, portBase, log);
|
|
402
624
|
} catch (e) {
|
|
403
625
|
log.warn(`SSH key install failed (SSH transport will fall back to password): ${e}`);
|
|
404
626
|
}
|
|
@@ -416,5 +638,5 @@ export async function provision(
|
|
|
416
638
|
await disableAdmin(httpPort, verifyAuth);
|
|
417
639
|
}
|
|
418
640
|
|
|
419
|
-
return { user: effectiveUser };
|
|
641
|
+
return { user: effectiveUser, managedSshKey };
|
|
420
642
|
}
|
package/src/lib/quickchr.ts
CHANGED
|
@@ -7,19 +7,25 @@ import type {
|
|
|
7
7
|
Channel,
|
|
8
8
|
ChrInstance,
|
|
9
9
|
ChrLoadSample,
|
|
10
|
+
CustomForward,
|
|
11
|
+
Descriptor,
|
|
10
12
|
DeviceModeOptions,
|
|
11
13
|
DoctorResult,
|
|
12
14
|
ExecOptions,
|
|
13
15
|
ExecResult,
|
|
14
16
|
LicenseInput,
|
|
15
17
|
LicenseLevel,
|
|
16
|
-
MachineDescriptor,
|
|
17
18
|
MachineState,
|
|
19
|
+
NetworkTopologyEntry,
|
|
20
|
+
PortMapping,
|
|
18
21
|
QgaCommand,
|
|
22
|
+
ServiceEndpoint,
|
|
19
23
|
SnapshotInfo,
|
|
24
|
+
SshServiceEndpoint,
|
|
20
25
|
StartOptions,
|
|
21
26
|
} from "./types.ts";
|
|
22
|
-
import { QuickCHRError, ARCHES, CHANNELS } from "./types.ts";
|
|
27
|
+
import { QuickCHRError, ARCHES, CHANNELS, SERVICE_IDS, QUICKCHR_DESCRIPTOR_VERSION } from "./types.ts";
|
|
28
|
+
import packageJson from "../../package.json";
|
|
23
29
|
import { detectPlatform, requireQemu, requireFirmware, getQemuVersion, getQemuInstallHint, isCrossArchEmulation, accelTimeoutFactor, detectAccel, findQemuImg, qgaKvmWarning, detectSocketVmnet, isSocketVmnetDaemonRunning, findCommandOnPath } from "./platform.ts";
|
|
24
30
|
import {
|
|
25
31
|
resolveVersion,
|
|
@@ -56,7 +62,7 @@ import { provision } from "./provision.ts";
|
|
|
56
62
|
import { renewLicense, getLicenseInfo } from "./license.ts";
|
|
57
63
|
import { resolveAuth, resolveCreds } from "./auth.ts";
|
|
58
64
|
import { scpPush, scpPull } from "./scp.ts";
|
|
59
|
-
import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
|
|
65
|
+
import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
|
|
60
66
|
import { restExecute } from "./exec.ts";
|
|
61
67
|
import { qgaExec } from "./qga.ts";
|
|
62
68
|
import { consoleExec } from "./console.ts";
|
|
@@ -86,7 +92,7 @@ function defaultMem(arch: Arch, override?: number): number {
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
/** Default boot timeout — accel mode and cross-arch emulation affect how slow the VM boots. */
|
|
89
|
-
function defaultBootTimeout(arch: Arch, withPackages?: boolean, accel?: string): number {
|
|
95
|
+
export function defaultBootTimeout(arch: Arch, withPackages?: boolean, accel?: string): number {
|
|
90
96
|
const cross = isCrossArchEmulation(arch);
|
|
91
97
|
const factor = accelTimeoutFactor(accel ?? "tcg", cross);
|
|
92
98
|
// Base: 120s for native, scaled by factor (TCG cross-arch = 15× = 1800s max)
|
|
@@ -242,46 +248,157 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
242
248
|
BASICAUTH: rawCreds,
|
|
243
249
|
};
|
|
244
250
|
};
|
|
245
|
-
|
|
251
|
+
// Service keys beyond these five are surfaced as `customForwards` (winbox, plus
|
|
252
|
+
// any user-added extraPorts) — see docs/centrs-interface.md "CustomForward".
|
|
253
|
+
const CANONICAL_PORT_NAMES = new Set(["http", "https", "api", "api-ssl", "ssh"]);
|
|
254
|
+
|
|
255
|
+
const buildDescriptor = (): Descriptor => {
|
|
246
256
|
if (state.status !== "running") {
|
|
247
257
|
throw new QuickCHRError(
|
|
248
258
|
"MACHINE_STOPPED",
|
|
249
259
|
`Machine "${state.name}" must be running to inspect its connection environment (current status: ${state.status}).`,
|
|
250
260
|
);
|
|
251
261
|
}
|
|
262
|
+
|
|
252
263
|
const auth = resolveAuth(state);
|
|
253
|
-
const
|
|
254
|
-
const basic =
|
|
264
|
+
const creds = resolveCreds(state);
|
|
265
|
+
const basic = `${creds.user}:${creds.password}`;
|
|
266
|
+
const storedCreds = getInstanceCredentials(state.name);
|
|
267
|
+
// resolveAuth/resolveCreds never throw — when disableAdmin is true and no
|
|
268
|
+
// provisioned/stored user exists, they still fall back to the meaningless
|
|
269
|
+
// admin:"" tuple (auth.ts docstring: "the caller will get a 401"). Don't
|
|
270
|
+
// report a service available:true off that fallback alone.
|
|
271
|
+
const disableAdminLockout = state.disableAdmin === true && !state.user && !storedCreds;
|
|
272
|
+
// A provisioned user whose password is the STORED_IN_SECRETS_PASSWORD sentinel
|
|
273
|
+
// only resolves a real password when the per-instance credential store actually
|
|
274
|
+
// has an entry — otherwise resolveAuth/resolveCreds fall through to returning the
|
|
275
|
+
// literal sentinel string as the "password". Don't report available:true (or leak
|
|
276
|
+
// the sentinel as a usable password) off that unresolvable case either, regardless
|
|
277
|
+
// of disableAdmin.
|
|
278
|
+
const sentinelUnresolvable = state.user?.password === STORED_IN_SECRETS_PASSWORD && !storedCreds;
|
|
279
|
+
const credentialsAvailable = !disableAdminLockout && !sentinelUnresolvable;
|
|
280
|
+
|
|
281
|
+
const buildHttpService = (
|
|
282
|
+
securePM: PortMapping | undefined,
|
|
283
|
+
plainPM: PortMapping | undefined,
|
|
284
|
+
serviceLabel: string,
|
|
285
|
+
scheme: { secure: string; plain: string },
|
|
286
|
+
authObj: { username: string; password?: string; basic?: string; header?: string },
|
|
287
|
+
urlSuffix = "",
|
|
288
|
+
): ServiceEndpoint => {
|
|
289
|
+
// Plain-first (#95): on a stock CHR the TLS services are not dialable —
|
|
290
|
+
// `www-ssl` is disabled and `api-ssl` is certificate-less (TLS alert 40) —
|
|
291
|
+
// so preferring the secure forward advertised dead endpoints. Prefer the
|
|
292
|
+
// plain forward for each service; the secure forward remains the fallback
|
|
293
|
+
// so excluded plain ports still resolve when a secure forward is available
|
|
294
|
+
// (with `tls: true`). Secure preference can return once boot provisioning
|
|
295
|
+
// installs a certificate and enables www-ssl.
|
|
296
|
+
const chosen = plainPM ?? securePM;
|
|
297
|
+
if (!chosen) {
|
|
298
|
+
return { available: false, unavailableReason: `no forwarded port for ${serviceLabel}` };
|
|
299
|
+
}
|
|
300
|
+
const tls = chosen === securePM;
|
|
301
|
+
const echo = {
|
|
302
|
+
host: "127.0.0.1",
|
|
303
|
+
port: chosen.host,
|
|
304
|
+
guestPort: chosen.guest,
|
|
305
|
+
transport: chosen.proto,
|
|
306
|
+
tls,
|
|
307
|
+
url: `${tls ? scheme.secure : scheme.plain}://127.0.0.1:${chosen.host}${urlSuffix}`,
|
|
308
|
+
source: { provider: "quickchr" as const, portMappingName: chosen.name },
|
|
309
|
+
};
|
|
310
|
+
if (!credentialsAvailable) {
|
|
311
|
+
return { available: false, unavailableReason: "admin disabled, no user provisioned", ...echo };
|
|
312
|
+
}
|
|
313
|
+
return { available: true, ...echo, auth: authObj };
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Preference order lives in buildHttpService (plain-first, #95); this also
|
|
317
|
+
// keeps the excludePorts fix: excluding "http" while keeping "https" now
|
|
318
|
+
// resolves rest-api onto the surviving secure port instead of a port that
|
|
319
|
+
// doesn't exist.
|
|
320
|
+
const restApi = buildHttpService(
|
|
321
|
+
state.ports.https,
|
|
322
|
+
state.ports.http,
|
|
323
|
+
"rest-api",
|
|
324
|
+
{ secure: "https", plain: "http" },
|
|
325
|
+
{ username: creds.user, password: creds.password, basic, header: auth.header },
|
|
326
|
+
"/rest",
|
|
327
|
+
);
|
|
328
|
+
const nativeApi = buildHttpService(
|
|
329
|
+
state.ports["api-ssl"],
|
|
330
|
+
state.ports.api,
|
|
331
|
+
"native-api",
|
|
332
|
+
{ secure: "tls", plain: "tcp" },
|
|
333
|
+
{ username: creds.user, password: creds.password },
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const sshPM = state.ports.ssh;
|
|
337
|
+
const sshUsername = state.user?.name ?? "admin";
|
|
338
|
+
const managedKey = state.managedSshKey;
|
|
339
|
+
const batchVerified = managedKey?.batchVerified === true;
|
|
340
|
+
const sshModes: Array<"private-key" | "agent-or-config" | "password"> = [];
|
|
341
|
+
if (managedKey) sshModes.push("private-key");
|
|
342
|
+
sshModes.push("agent-or-config");
|
|
343
|
+
if (credentialsAvailable) sshModes.push("password");
|
|
344
|
+
|
|
345
|
+
const sshService: SshServiceEndpoint = !sshPM
|
|
346
|
+
? { available: false, unavailableReason: "no forwarded port for ssh" }
|
|
347
|
+
: {
|
|
348
|
+
available: true,
|
|
349
|
+
host: "127.0.0.1",
|
|
350
|
+
port: sshPM.host,
|
|
351
|
+
guestPort: sshPM.guest,
|
|
352
|
+
transport: sshPM.proto,
|
|
353
|
+
tls: false,
|
|
354
|
+
url: `ssh://${sshUsername}@127.0.0.1:${sshPM.host}`,
|
|
355
|
+
source: { provider: "quickchr" as const, portMappingName: "ssh" },
|
|
356
|
+
auth: {
|
|
357
|
+
username: sshUsername,
|
|
358
|
+
...(batchVerified && managedKey ? { privateKeyPath: managedKey.privateKeyPath } : {}),
|
|
359
|
+
modes: sshModes,
|
|
360
|
+
// Only "private-key" is ever vouched for here, and only once verified —
|
|
361
|
+
// "agent-or-config" is host ssh-agent/~/.ssh/config policy quickchr has
|
|
362
|
+
// no way to check, so it stays out of batchModes (Done-when's own
|
|
363
|
+
// unverified/absent-key example expects batchModes: [], not
|
|
364
|
+
// ["agent-or-config"]).
|
|
365
|
+
batchModes: batchVerified ? ["private-key"] : [],
|
|
366
|
+
passwordAvailable: credentialsAvailable,
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const customForwards: CustomForward[] = Object.entries(state.ports)
|
|
371
|
+
.filter(([name]) => !CANONICAL_PORT_NAMES.has(name))
|
|
372
|
+
.map(([name, pm]) => ({
|
|
373
|
+
name,
|
|
374
|
+
transport: pm.proto,
|
|
375
|
+
host: "127.0.0.1",
|
|
376
|
+
hostPort: pm.host,
|
|
377
|
+
guestPort: pm.guest,
|
|
378
|
+
}));
|
|
379
|
+
|
|
380
|
+
const networks: NetworkTopologyEntry[] = state.networks.map((n) => ({ id: n.id, specifier: n.specifier }));
|
|
381
|
+
|
|
255
382
|
return {
|
|
256
|
-
|
|
383
|
+
descriptorVersion: QUICKCHR_DESCRIPTOR_VERSION,
|
|
384
|
+
quickchr: { packageVersion: packageJson.version },
|
|
257
385
|
status: "running",
|
|
386
|
+
name: state.name,
|
|
258
387
|
version: state.version,
|
|
259
388
|
arch: state.arch,
|
|
260
389
|
cpu: state.cpu,
|
|
261
390
|
mem: state.mem,
|
|
262
391
|
pid: state.pid ?? null,
|
|
263
|
-
ports,
|
|
264
|
-
portMappings: state.ports,
|
|
265
|
-
urls: {
|
|
266
|
-
http: restUrl,
|
|
267
|
-
rest: `${restUrl}/rest`,
|
|
268
|
-
restBase: `${restUrl}/rest`,
|
|
269
|
-
https: ports.https ? `https://127.0.0.1:${ports.https}` : undefined,
|
|
270
|
-
ssh: ports.ssh ? `ssh://${auth.user}@127.0.0.1:${ports.ssh}` : undefined,
|
|
271
|
-
api: ports.api ? `tcp://127.0.0.1:${ports.api}` : undefined,
|
|
272
|
-
apiSsl: ports.apiSsl ? `tls://127.0.0.1:${ports.apiSsl}` : undefined,
|
|
273
|
-
winbox: ports.winbox ? `tcp://127.0.0.1:${ports.winbox}` : undefined,
|
|
274
|
-
},
|
|
275
|
-
auth: {
|
|
276
|
-
user: auth.user,
|
|
277
|
-
password: basic.slice(auth.user.length + 1),
|
|
278
|
-
basic,
|
|
279
|
-
header: auth.header,
|
|
280
|
-
},
|
|
281
|
-
env,
|
|
282
392
|
machineDir: state.machineDir,
|
|
283
393
|
createdAt: state.createdAt,
|
|
284
394
|
lastStartedAt: state.lastStartedAt ?? null,
|
|
395
|
+
services: {
|
|
396
|
+
[SERVICE_IDS.restApi]: restApi,
|
|
397
|
+
[SERVICE_IDS.nativeApi]: nativeApi,
|
|
398
|
+
[SERVICE_IDS.ssh]: sshService,
|
|
399
|
+
},
|
|
400
|
+
...(customForwards.length > 0 ? { customForwards } : {}),
|
|
401
|
+
networks,
|
|
285
402
|
};
|
|
286
403
|
};
|
|
287
404
|
|
|
@@ -636,7 +753,7 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
636
753
|
return buildEnv();
|
|
637
754
|
},
|
|
638
755
|
|
|
639
|
-
async descriptor(): Promise<
|
|
756
|
+
async descriptor(): Promise<Descriptor> {
|
|
640
757
|
return buildDescriptor();
|
|
641
758
|
},
|
|
642
759
|
|
|
@@ -1643,14 +1760,25 @@ export class QuickCHR {
|
|
|
1643
1760
|
}
|
|
1644
1761
|
|
|
1645
1762
|
if (opts.user || opts.disableAdmin || opts.secureLogin === true) {
|
|
1646
|
-
const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase);
|
|
1647
|
-
if (result.user) {
|
|
1648
|
-
// Persist user info in state (password placeholder —
|
|
1763
|
+
const result = await provision(chrPorts.http, machineState.name, opts.user, opts.disableAdmin, opts.secureLogin, log, machineState.machineDir, machineState.portBase, chrPorts.ssh);
|
|
1764
|
+
if (result.user || result.managedSshKey) {
|
|
1765
|
+
// Persist user info + managed SSH key fact in state (password placeholder —
|
|
1766
|
+
// real password in secret store). The managedSshKey fact is what the #71
|
|
1767
|
+
// descriptor reads to advertise SSH private-key batch auth (only when verified).
|
|
1649
1768
|
const current = loadMachine(machineState.name);
|
|
1650
1769
|
if (current) {
|
|
1651
|
-
current.user = { name: result.user.name, password: STORED_IN_SECRETS_PASSWORD };
|
|
1770
|
+
if (result.user) current.user = { name: result.user.name, password: STORED_IN_SECRETS_PASSWORD };
|
|
1771
|
+
if (result.managedSshKey) current.managedSshKey = result.managedSshKey;
|
|
1652
1772
|
saveMachine(current);
|
|
1653
1773
|
}
|
|
1774
|
+
}
|
|
1775
|
+
if (result.managedSshKey) {
|
|
1776
|
+
machineState.managedSshKey = result.managedSshKey;
|
|
1777
|
+
if (!result.managedSshKey.batchVerified) {
|
|
1778
|
+
log.warn(" SSH key installed but batch login could not be verified — SSH transport may fall back to password");
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
if (result.user) {
|
|
1654
1782
|
machineState.user = { name: result.user.name, password: result.user.password };
|
|
1655
1783
|
if (!opts.user) {
|
|
1656
1784
|
// Auto-created quickchr account — credential display is handled by the caller (wizard/CLI)
|
package/src/lib/types.ts
CHANGED
|
@@ -215,6 +215,26 @@ export interface MachineConfig {
|
|
|
215
215
|
bootDiskFormat?: BootDiskFormat;
|
|
216
216
|
}
|
|
217
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
|
+
|
|
218
238
|
export interface MachineState extends MachineConfig {
|
|
219
239
|
createdAt: string;
|
|
220
240
|
lastStartedAt?: string;
|
|
@@ -226,6 +246,8 @@ export interface MachineState extends MachineConfig {
|
|
|
226
246
|
lastAccel?: string;
|
|
227
247
|
/** Wall-clock ms from QEMU spawn to REST-ready for the most recent boot. */
|
|
228
248
|
lastBootMs?: number;
|
|
249
|
+
/** Managed SSH key installed during provisioning, if any (issue #74). */
|
|
250
|
+
managedSshKey?: ManagedSshKey;
|
|
229
251
|
}
|
|
230
252
|
|
|
231
253
|
// --- Start Options ---
|
|
@@ -361,36 +383,138 @@ export interface ChrPorts {
|
|
|
361
383
|
[key: string]: number;
|
|
362
384
|
}
|
|
363
385
|
|
|
364
|
-
|
|
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 {
|
|
365
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 };
|
|
366
495
|
status: "running";
|
|
496
|
+
|
|
497
|
+
name: string;
|
|
367
498
|
version: string;
|
|
368
499
|
arch: Arch;
|
|
369
500
|
cpu: number;
|
|
370
501
|
mem: number;
|
|
371
502
|
pid: number | null;
|
|
372
|
-
ports: ChrPorts;
|
|
373
|
-
portMappings: Record<string, PortMapping>;
|
|
374
|
-
urls: {
|
|
375
|
-
http: string;
|
|
376
|
-
rest: string;
|
|
377
|
-
restBase: string;
|
|
378
|
-
https?: string;
|
|
379
|
-
ssh?: string;
|
|
380
|
-
api?: string;
|
|
381
|
-
apiSsl?: string;
|
|
382
|
-
winbox?: string;
|
|
383
|
-
};
|
|
384
|
-
auth: {
|
|
385
|
-
user: string;
|
|
386
|
-
password: string;
|
|
387
|
-
basic: string;
|
|
388
|
-
header: string;
|
|
389
|
-
};
|
|
390
|
-
env: Record<string, string>;
|
|
391
503
|
machineDir: string;
|
|
392
504
|
createdAt: string;
|
|
393
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[];
|
|
394
518
|
}
|
|
395
519
|
|
|
396
520
|
/** Return value of ChrInstance.queryLoad(). */
|
|
@@ -549,8 +673,9 @@ export interface ChrInstance {
|
|
|
549
673
|
* Bun.spawn(["bun", "my-script.ts"], { env: { ...process.env, ...await chr.subprocessEnv() } })
|
|
550
674
|
*/
|
|
551
675
|
subprocessEnv(): Promise<Record<string, string>>;
|
|
552
|
-
/** Build a stable machine-readable connection descriptor for a running instance
|
|
553
|
-
|
|
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>;
|
|
554
679
|
|
|
555
680
|
/** Sample QEMU guest load from the monitor. Returns null when the monitor is
|
|
556
681
|
* unavailable (machine stopped or running in foreground mode). */
|