@tikoci/quickchr 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +161 -37
- package/MANUAL.md +1271 -0
- package/README.md +54 -10
- package/package.json +7 -4
- package/src/cli/index.ts +211 -48
- package/src/cli/wizard.ts +42 -12
- package/src/index.ts +7 -3
- package/src/lib/cache.ts +19 -1
- package/src/lib/channels.ts +19 -4
- package/src/lib/completions.ts +6 -3
- package/src/lib/disk.ts +5 -2
- package/src/lib/forward-spec.ts +140 -3
- package/src/lib/paths.ts +13 -0
- package/src/lib/provision.ts +279 -59
- package/src/lib/qemu.ts +52 -22
- package/src/lib/quickchr.ts +237 -48
- package/src/lib/secrets.ts +3 -7
- package/src/lib/settings.ts +373 -0
- package/src/lib/state.ts +42 -1
- package/src/lib/types.ts +237 -30
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
|
|
|
@@ -364,20 +585,18 @@ export async function provision(
|
|
|
364
585
|
// Try REST provisioning. When machineDir is provided, use a shorter timeout
|
|
365
586
|
// so the console fallback can engage sooner if REST isn't available.
|
|
366
587
|
const restTimeout = machineDir ? 30_000 : 60_000;
|
|
367
|
-
let usedConsolePath = false;
|
|
368
588
|
try {
|
|
369
589
|
await waitForRest(httpPort, restTimeout);
|
|
370
590
|
} catch (e) {
|
|
371
591
|
if (machineDir) {
|
|
372
592
|
log.warn("REST API unavailable — falling back to serial console provisioning");
|
|
373
593
|
const result = await consoleProvision(machineDir, machineName, effectiveUser, !!shouldDisableAdmin, logger, portBase);
|
|
374
|
-
usedConsolePath = true;
|
|
375
594
|
// After console provisioning the machine is booted; REST comes up shortly.
|
|
376
595
|
// Attempt SSH key install for the managed quickchr user — non-fatal if REST is still unavailable.
|
|
377
596
|
if (secureLogin && !user && effectiveUser) {
|
|
378
597
|
try {
|
|
379
598
|
await waitForRest(httpPort, 30_000);
|
|
380
|
-
await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
|
|
599
|
+
result.managedSshKey = await installSshKey(httpPort, sshPort ?? 0, effectiveUser.name, machineName, machineDir, portBase, log);
|
|
381
600
|
} catch (keyErr) {
|
|
382
601
|
log.warn(`SSH key install failed after console provisioning (SSH transport will fall back to password): ${keyErr}`);
|
|
383
602
|
}
|
|
@@ -387,36 +606,37 @@ export async function provision(
|
|
|
387
606
|
throw e;
|
|
388
607
|
}
|
|
389
608
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
609
|
+
// REST provisioning path. The console fallback above always returns early,
|
|
610
|
+
// so reaching here means REST came up and we provision over it.
|
|
611
|
+
if (effectiveUser) {
|
|
612
|
+
await createUser(httpPort, effectiveUser.name, effectiveUser.password);
|
|
613
|
+
// Persist to secret store so resolveAuth() picks it up
|
|
614
|
+
saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
|
|
615
|
+
}
|
|
396
616
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
617
|
+
let managedSshKey: ManagedSshKey | undefined;
|
|
618
|
+
if (secureLogin && !user && effectiveUser && machineDir) {
|
|
619
|
+
// Brief pause so RouterOS propagates the new user to all subsystems
|
|
620
|
+
// (including the SSH key store) before we attempt key installation.
|
|
621
|
+
await Bun.sleep(1000);
|
|
622
|
+
try {
|
|
623
|
+
managedSshKey = await installSshKey(httpPort, sshPort ?? 0, effectiveUser.name, machineName, machineDir, portBase, log);
|
|
624
|
+
} catch (e) {
|
|
625
|
+
log.warn(`SSH key install failed (SSH transport will fall back to password): ${e}`);
|
|
406
626
|
}
|
|
627
|
+
}
|
|
407
628
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
// Pass the new user's auth for verification — after disabling admin,
|
|
413
|
-
// admin creds may stop working for REST queries.
|
|
414
|
-
const verifyAuth = effectiveUser
|
|
415
|
-
? `Basic ${btoa(`${effectiveUser.name}:${effectiveUser.password}`)}`
|
|
416
|
-
: undefined;
|
|
417
|
-
await disableAdmin(httpPort, verifyAuth);
|
|
629
|
+
if (shouldDisableAdmin) {
|
|
630
|
+
if (!effectiveUser) {
|
|
631
|
+
log.warn("Warning: disabling admin without creating another user — you may lose access");
|
|
418
632
|
}
|
|
633
|
+
// Pass the new user's auth for verification — after disabling admin,
|
|
634
|
+
// admin creds may stop working for REST queries.
|
|
635
|
+
const verifyAuth = effectiveUser
|
|
636
|
+
? `Basic ${btoa(`${effectiveUser.name}:${effectiveUser.password}`)}`
|
|
637
|
+
: undefined;
|
|
638
|
+
await disableAdmin(httpPort, verifyAuth);
|
|
419
639
|
}
|
|
420
640
|
|
|
421
|
-
return { user: effectiveUser };
|
|
641
|
+
return { user: effectiveUser, managedSshKey };
|
|
422
642
|
}
|
package/src/lib/qemu.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, writeFileSync, readFileSync, copyFileSync, statSync, openSy
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import type { Arch, BootDiskFormat, MachineState, NetworkMode, NetworkConfig, PortMapping } from "./types.ts";
|
|
11
11
|
import { QuickCHRError } from "./types.ts";
|
|
12
|
-
import { detectAccel, requireQemu, requireFirmware } from "./platform.ts";
|
|
12
|
+
import { detectAccel, requireQemu, requireFirmware, requireQemuImg } from "./platform.ts";
|
|
13
13
|
import { buildHostfwdString } from "./network.ts";
|
|
14
14
|
import { ensureDir } from "./state.ts";
|
|
15
15
|
import { restGet } from "./rest.ts";
|
|
@@ -80,16 +80,19 @@ export async function buildQemuArgs(config: QemuLaunchConfig): Promise<string[]>
|
|
|
80
80
|
// UEFI firmware (arm64 only)
|
|
81
81
|
if (arch === "arm64") {
|
|
82
82
|
const fw = requireFirmware();
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
83
|
+
// The per-machine EFI vars pflash is qcow2, NOT raw: QEMU refuses
|
|
84
|
+
// savevm/loadvm while any writable block device is non-qcow2 ("Device
|
|
85
|
+
// 'pflash1' is writable but does not support snapshots"), which made
|
|
86
|
+
// snapshots impossible on arm64 (#31). Legacy raw efi-vars.fd machines
|
|
87
|
+
// are converted in place, preserving their NVRAM state.
|
|
88
|
+
const varsPath = join(machineDir, "efi-vars.qcow2");
|
|
86
89
|
if (!existsSync(varsPath)) {
|
|
87
|
-
prepareEfiVars(fw.code, fw.vars, varsPath);
|
|
90
|
+
await prepareEfiVars(fw.code, fw.vars, varsPath, join(machineDir, "efi-vars.fd"));
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
args.push(
|
|
91
94
|
"-drive", `if=pflash,format=raw,readonly=on,unit=0,file=${fw.code}`,
|
|
92
|
-
"-drive", `if=pflash,format=
|
|
95
|
+
"-drive", `if=pflash,format=qcow2,unit=1,file=${varsPath}`,
|
|
93
96
|
);
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -248,26 +251,53 @@ function buildChannelArgs(
|
|
|
248
251
|
}
|
|
249
252
|
}
|
|
250
253
|
|
|
251
|
-
/**
|
|
252
|
-
|
|
254
|
+
/** Build the per-machine qcow2 EFI vars pflash: start from the legacy raw
|
|
255
|
+
* vars file if the machine has one (preserves NVRAM state), else from the
|
|
256
|
+
* firmware template; size-match to the code ROM; convert raw → qcow2 so
|
|
257
|
+
* savevm/loadvm work (see the pflash comment at the call site / #31). */
|
|
258
|
+
async function prepareEfiVars(codePath: string, varsTemplatePath: string, destPath: string, legacyRawPath: string): Promise<void> {
|
|
253
259
|
ensureDir(join(destPath, ".."));
|
|
254
|
-
copyFileSync(varsTemplatePath, destPath);
|
|
255
260
|
|
|
256
|
-
//
|
|
261
|
+
// Stage the raw image (legacy per-machine file wins over the template).
|
|
262
|
+
const rawStage = `${destPath}.raw-stage`;
|
|
263
|
+
copyFileSync(existsSync(legacyRawPath) ? legacyRawPath : varsTemplatePath, rawStage);
|
|
264
|
+
|
|
265
|
+
// pflash units must be identical size — pad/truncate vars to match code.
|
|
266
|
+
// truncateSync extends with zero bytes when target is larger.
|
|
257
267
|
const codeSize = statSync(codePath).size;
|
|
258
|
-
|
|
268
|
+
try {
|
|
269
|
+
if (statSync(rawStage).size !== codeSize) truncateSync(rawStage, codeSize);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
try { unlinkSync(rawStage); } catch { /* best effort */ }
|
|
272
|
+
throw new QuickCHRError(
|
|
273
|
+
"PROCESS_FAILED",
|
|
274
|
+
`Failed to size-match EFI vars: ${e instanceof Error ? e.message : String(e)}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
259
277
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
278
|
+
const qemuImg = requireQemuImg();
|
|
279
|
+
const conversion = Bun.spawn([qemuImg, "convert", "-f", "raw", "-O", "qcow2", rawStage, destPath], {
|
|
280
|
+
stdout: "pipe",
|
|
281
|
+
stderr: "pipe",
|
|
282
|
+
});
|
|
283
|
+
const exitCode = await conversion.exited;
|
|
284
|
+
const [stderr, stdout] = await Promise.all([
|
|
285
|
+
new Response(conversion.stderr).text(),
|
|
286
|
+
new Response(conversion.stdout).text(),
|
|
287
|
+
]);
|
|
288
|
+
try { unlinkSync(rawStage); } catch { /* best effort */ }
|
|
289
|
+
if (exitCode !== 0) {
|
|
290
|
+
// A failed convert can leave a partial destPath behind — remove it so the
|
|
291
|
+
// next launch regenerates instead of booting (and re-failing on) the stub.
|
|
292
|
+
try { unlinkSync(destPath); } catch { /* best effort */ }
|
|
293
|
+
throw new QuickCHRError(
|
|
294
|
+
"PROCESS_FAILED",
|
|
295
|
+
`qemu-img convert of EFI vars failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
// Legacy raw file is superseded — remove so nothing boots the stale copy.
|
|
299
|
+
if (existsSync(legacyRawPath)) {
|
|
300
|
+
try { unlinkSync(legacyRawPath); } catch { /* best effort */ }
|
|
271
301
|
}
|
|
272
302
|
}
|
|
273
303
|
|