@tikoci/quickchr 0.4.1 → 0.4.3
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 +165 -41
- package/MANUAL.md +1259 -0
- package/README.md +54 -1
- package/package.json +7 -4
- package/src/cli/index.ts +272 -73
- package/src/cli/wizard.ts +42 -12
- package/src/index.ts +18 -1
- 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/net.ts +65 -41
- package/src/lib/paths.ts +13 -0
- package/src/lib/provision.ts +25 -27
- package/src/lib/qemu.ts +52 -22
- package/src/lib/quickchr.ts +84 -17
- 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 +92 -9
- package/src/lib/versions.ts +107 -15
package/src/cli/wizard.ts
CHANGED
|
@@ -5,12 +5,43 @@
|
|
|
5
5
|
import type { Arch, Channel, DeviceModeOptions, LicenseLevel, LicenseOptions, NetworkSpecifier, StartOptions } from "../lib/types.ts";
|
|
6
6
|
import { CHANNELS, ARCHES, knownPackagesForArch } from "../lib/types.ts";
|
|
7
7
|
|
|
8
|
+
export type WizardUserChoice = "custom" | "managed" | "admin";
|
|
9
|
+
|
|
10
|
+
export interface WizardUserChoiceResult {
|
|
11
|
+
user?: { name: string; password: string };
|
|
12
|
+
disableAdmin: boolean;
|
|
13
|
+
secureLogin?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Maps the wizard's "CHR login setup" choice to StartOptions fields. Pulled out as a
|
|
18
|
+
* pure function (rather than inlined in runWizard) so it's unit-testable without
|
|
19
|
+
* mocking the entire @clack/prompts prompt sequence — "managed" must set both
|
|
20
|
+
* disableAdmin AND secureLogin, since provision() only auto-creates the replacement
|
|
21
|
+
* quickchr account when secureLogin === true (src/lib/provision.ts); disableAdmin
|
|
22
|
+
* alone would disable the default account with no replacement login provisioned.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveUserChoiceOptions(
|
|
25
|
+
userChoice: WizardUserChoice,
|
|
26
|
+
customUser?: { name: string; password: string },
|
|
27
|
+
): WizardUserChoiceResult {
|
|
28
|
+
if (userChoice === "custom") {
|
|
29
|
+
return { user: customUser, disableAdmin: true };
|
|
30
|
+
}
|
|
31
|
+
if (userChoice === "managed") {
|
|
32
|
+
return { disableAdmin: true, secureLogin: true };
|
|
33
|
+
}
|
|
34
|
+
// "admin" — keep admin with no password
|
|
35
|
+
return { disableAdmin: false, secureLogin: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
8
38
|
/** Run the interactive wizard using @clack/prompts. */
|
|
9
39
|
export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<void> {
|
|
10
40
|
// Dynamic import — only loaded when wizard is actually used
|
|
11
41
|
const clack = await import("@clack/prompts");
|
|
12
42
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
13
43
|
const { formatPorts, bold } = await import("./format.ts");
|
|
44
|
+
const { resolveSetting } = await import("../lib/settings.ts");
|
|
14
45
|
|
|
15
46
|
clack.intro("quickchr — MikroTik CHR Manager");
|
|
16
47
|
if (wizardOpts?.firstRun) {
|
|
@@ -31,6 +62,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
31
62
|
let channel: Channel | undefined;
|
|
32
63
|
|
|
33
64
|
if (versionSource === "channel") {
|
|
65
|
+
const defaultChannel = resolveSetting("default-channel").value as Channel;
|
|
34
66
|
const ch = await clack.select({
|
|
35
67
|
message: "Select channel:",
|
|
36
68
|
options: CHANNELS.map((c) => ({
|
|
@@ -38,7 +70,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
38
70
|
label: c,
|
|
39
71
|
hint: c === "long-term" ? "recommended for provisioning" : undefined,
|
|
40
72
|
})),
|
|
41
|
-
initialValue:
|
|
73
|
+
initialValue: defaultChannel,
|
|
42
74
|
});
|
|
43
75
|
if (clack.isCancel(ch)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
44
76
|
channel = ch;
|
|
@@ -57,6 +89,8 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
57
89
|
|
|
58
90
|
// 2. Architecture
|
|
59
91
|
const hostArch = process.arch === "arm64" ? "arm64" : "x86";
|
|
92
|
+
const settingArch = resolveSetting("default-arch").value as Arch | "auto";
|
|
93
|
+
const archInitial: Arch = settingArch === "auto" ? hostArch : settingArch;
|
|
60
94
|
const arch = await clack.select({
|
|
61
95
|
message: "Architecture:",
|
|
62
96
|
options: ARCHES.map((a) => ({
|
|
@@ -64,7 +98,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
64
98
|
label: a,
|
|
65
99
|
hint: a === hostArch ? "host native" : "emulated (slower)",
|
|
66
100
|
})),
|
|
67
|
-
initialValue:
|
|
101
|
+
initialValue: archInitial,
|
|
68
102
|
});
|
|
69
103
|
if (clack.isCancel(arch)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
70
104
|
|
|
@@ -304,7 +338,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
304
338
|
} = await import("../lib/versions.ts");
|
|
305
339
|
let resolvedVersion = version ?? await resolveVersion(channel ?? "long-term");
|
|
306
340
|
version = resolvedVersion;
|
|
307
|
-
|
|
341
|
+
const provisioningSupported = isProvisioningSupportedVersion(resolvedVersion);
|
|
308
342
|
|
|
309
343
|
let installAllPackages = false;
|
|
310
344
|
let packages: string[] = [];
|
|
@@ -346,7 +380,6 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
346
380
|
channel = "long-term";
|
|
347
381
|
resolvedVersion = await resolveVersion(channel);
|
|
348
382
|
version = resolvedVersion;
|
|
349
|
-
provisioningSupported = true;
|
|
350
383
|
wantProvision = await askProvisionChoice();
|
|
351
384
|
}
|
|
352
385
|
}
|
|
@@ -428,6 +461,7 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
428
461
|
}
|
|
429
462
|
|
|
430
463
|
// 6c. User setup
|
|
464
|
+
const secureLoginSetting = resolveSetting("secure-login").value;
|
|
431
465
|
const userChoice = await clack.select({
|
|
432
466
|
message: "CHR login setup:",
|
|
433
467
|
options: [
|
|
@@ -435,10 +469,11 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
435
469
|
{ value: "custom", label: "Custom user", hint: "you provide username + password" },
|
|
436
470
|
{ value: "admin", label: "Keep admin with no password", hint: "less secure" },
|
|
437
471
|
],
|
|
438
|
-
initialValue: "managed",
|
|
472
|
+
initialValue: secureLoginSetting === false ? "admin" : "managed",
|
|
439
473
|
});
|
|
440
474
|
if (clack.isCancel(userChoice)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
441
475
|
|
|
476
|
+
let customUser: { name: string; password: string } | undefined;
|
|
442
477
|
if (userChoice === "custom") {
|
|
443
478
|
const userName = await clack.text({
|
|
444
479
|
message: "Username:",
|
|
@@ -449,14 +484,9 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
449
484
|
const userPass = await clack.password({ message: "Password:" });
|
|
450
485
|
if (clack.isCancel(userPass)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
451
486
|
|
|
452
|
-
|
|
453
|
-
disableAdmin = true;
|
|
454
|
-
} else if (userChoice === "managed") {
|
|
455
|
-
disableAdmin = true;
|
|
456
|
-
} else {
|
|
457
|
-
// "admin" — keep admin with no password
|
|
458
|
-
secureLogin = false;
|
|
487
|
+
customUser = { name: userName, password: userPass };
|
|
459
488
|
}
|
|
489
|
+
({ user, disableAdmin, secureLogin } = resolveUserChoiceOptions(userChoice as WizardUserChoice, customUser));
|
|
460
490
|
|
|
461
491
|
// 6d. License
|
|
462
492
|
const wantLicense = await clack.confirm({
|
package/src/index.ts
CHANGED
|
@@ -125,8 +125,25 @@ export { WELL_KNOWN_GUEST_PORTS, lookupGuestPort } from "./lib/guest-ports.ts";
|
|
|
125
125
|
export type { GuestPortDef } from "./lib/guest-ports.ts";
|
|
126
126
|
|
|
127
127
|
// Forward spec parsing
|
|
128
|
-
export { parseForwardSpec } from "./lib/forward-spec.ts";
|
|
128
|
+
export { parseForwardSpec, expandForwardSpec, FORWARD_RANGE_MAX } from "./lib/forward-spec.ts";
|
|
129
129
|
|
|
130
130
|
// Logging
|
|
131
131
|
export { createLogger } from "./lib/log.ts";
|
|
132
132
|
export type { ProgressLogger } from "./lib/log.ts";
|
|
133
|
+
|
|
134
|
+
// Version & channel utilities
|
|
135
|
+
export { CHANNELS } from "./lib/types.ts";
|
|
136
|
+
export {
|
|
137
|
+
resolveVersion,
|
|
138
|
+
resolveAllVersions,
|
|
139
|
+
isValidVersion,
|
|
140
|
+
parseVersionParts,
|
|
141
|
+
compareRouterOsVersion,
|
|
142
|
+
isProvisioningSupportedVersion,
|
|
143
|
+
channelMaturity,
|
|
144
|
+
classifyChannels,
|
|
145
|
+
selectActiveChannels,
|
|
146
|
+
resolveChannelStatuses,
|
|
147
|
+
resolveActiveChannels,
|
|
148
|
+
} from "./lib/versions.ts";
|
|
149
|
+
export type { ChannelMaturity, ChannelStatus } from "./lib/versions.ts";
|
package/src/lib/cache.ts
CHANGED
|
@@ -11,10 +11,28 @@ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { getCacheDir, loadAllMachines } from "./state.ts";
|
|
13
13
|
import { chrImageBasename, compareRouterOsVersion, isValidVersion } from "./versions.ts";
|
|
14
|
-
import type
|
|
14
|
+
import { QuickCHRError, type Arch } from "./types.ts";
|
|
15
15
|
|
|
16
16
|
export const DEFAULT_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
17
17
|
|
|
18
|
+
/** Parse a human size string (NK/NM/NG/NT, IEC binary, case-insensitive) to bytes. */
|
|
19
|
+
export function parseSizeString(s: string): number {
|
|
20
|
+
const m = s.match(/^(\d+(?:\.\d+)?)([KMGT]?)i?B?$/i);
|
|
21
|
+
if (!m) throw new QuickCHRError("INVALID_SIZE_STRING", `Invalid size "${s}" (use NK/NM/NG/NT, e.g. 2G)`);
|
|
22
|
+
const n = Number.parseFloat(m[1] ?? "0");
|
|
23
|
+
const unit = (m[2] ?? "").toUpperCase();
|
|
24
|
+
const mult: Record<string, number> = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
|
|
25
|
+
return Math.floor(n * (mult[unit] ?? 1));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Format a byte count as a human size (IEC binary), matching cache list's display. */
|
|
29
|
+
export function formatSizeBytes(b: number): string {
|
|
30
|
+
if (b >= 1024 ** 3) return `${(b / 1024 ** 3).toFixed(2)} GiB`;
|
|
31
|
+
if (b >= 1024 ** 2) return `${(b / 1024 ** 2).toFixed(1)} MiB`;
|
|
32
|
+
if (b >= 1024) return `${(b / 1024).toFixed(1)} KiB`;
|
|
33
|
+
return `${b} B`;
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
export interface CacheEntry {
|
|
19
37
|
path: string;
|
|
20
38
|
basename: string;
|
package/src/lib/channels.ts
CHANGED
|
@@ -54,7 +54,22 @@ export function channelFileExists(path: string): boolean {
|
|
|
54
54
|
return existsSync(path);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
57
|
+
/** Strip ANSI escapes and the monitor's per-character command echo, leaving
|
|
58
|
+
* just the response body. The echo hid real errors from callers: QEMU renders
|
|
59
|
+
* the typed command as incremental `cmd\x1b[K\x1b[D…` fragments before the
|
|
60
|
+
* first newline, so an `Error: …` response never appeared at string start and
|
|
61
|
+
* `savevm`/`loadvm` failures were silently swallowed (#31). */
|
|
62
|
+
export function cleanMonitorResponse(raw: string): string {
|
|
63
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes are the point
|
|
64
|
+
const noAnsi = raw.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
|
|
65
|
+
const lines = noAnsi.split(/\r?\n/);
|
|
66
|
+
// Everything before the first newline is the echoed command; the response
|
|
67
|
+
// (possibly multi-line, possibly empty) follows.
|
|
68
|
+
return lines.slice(1).join("\n").trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Send a command to the QEMU monitor and return the cleaned response
|
|
72
|
+
* (command echo and ANSI control sequences stripped — see cleanMonitorResponse). */
|
|
58
73
|
export async function monitorCommand(
|
|
59
74
|
machineDir: string,
|
|
60
75
|
command: string,
|
|
@@ -92,9 +107,9 @@ export async function monitorCommand(
|
|
|
92
107
|
buffer = "";
|
|
93
108
|
socket.write(command + "\n");
|
|
94
109
|
} else if (sentCommand && buffer.includes("(qemu)")) {
|
|
95
|
-
// Got response — strip the prompt
|
|
110
|
+
// Got response — strip the prompt, the command echo, and ANSI noise
|
|
96
111
|
clearTimeout(timeout);
|
|
97
|
-
const response = buffer.replace(/\(qemu\)\s*/g, "")
|
|
112
|
+
const response = cleanMonitorResponse(buffer.replace(/\(qemu\)\s*/g, ""));
|
|
98
113
|
socket.destroy();
|
|
99
114
|
resolve(response);
|
|
100
115
|
}
|
|
@@ -105,7 +120,7 @@ export async function monitorCommand(
|
|
|
105
120
|
socket.on("close", () => {
|
|
106
121
|
clearTimeout(timeout);
|
|
107
122
|
if (sentCommand) {
|
|
108
|
-
resolve(buffer.replace(/\(qemu\)\s*/g, "")
|
|
123
|
+
resolve(cleanMonitorResponse(buffer.replace(/\(qemu\)\s*/g, "")));
|
|
109
124
|
} else {
|
|
110
125
|
reject(new QuickCHRError("MACHINE_STOPPED", "Monitor socket closed before command could be sent"));
|
|
111
126
|
}
|
package/src/lib/completions.ts
CHANGED
|
@@ -328,7 +328,7 @@ _quickchr() {
|
|
|
328
328
|
cword=${D}COMP_CWORD
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help"
|
|
331
|
+
local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor settings setup completions version help"
|
|
332
332
|
|
|
333
333
|
if [[ ${D}cword -eq 1 ]]; then
|
|
334
334
|
COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
|
|
@@ -402,6 +402,7 @@ _quickchr() {
|
|
|
402
402
|
'networks:Network discovery and socket management'
|
|
403
403
|
'net:Network discovery (alias)'
|
|
404
404
|
'doctor:Check prerequisites'
|
|
405
|
+
'settings:Manage global quickchr settings'
|
|
405
406
|
'setup:Interactive setup wizard'
|
|
406
407
|
'completions:Manage shell completions'
|
|
407
408
|
'version:Show version info'
|
|
@@ -434,7 +435,7 @@ _quickchr() {
|
|
|
434
435
|
_describe 'option' opts
|
|
435
436
|
;;
|
|
436
437
|
help)
|
|
437
|
-
local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor setup completions version)
|
|
438
|
+
local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor settings setup completions version)
|
|
438
439
|
_describe 'command' commands
|
|
439
440
|
;;
|
|
440
441
|
esac
|
|
@@ -450,7 +451,7 @@ function generateFishCompletion(): string {
|
|
|
450
451
|
return `# quickchr fish completion
|
|
451
452
|
# Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
|
|
452
453
|
|
|
453
|
-
set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help
|
|
454
|
+
set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor settings setup completions version help
|
|
454
455
|
|
|
455
456
|
# Disable file completions globally for quickchr
|
|
456
457
|
complete -c quickchr -f
|
|
@@ -490,6 +491,8 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
490
491
|
-a "networks net" -d "Network discovery and socket management"
|
|
491
492
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
492
493
|
-a "doctor" -d "Check prerequisites"
|
|
494
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
495
|
+
-a "settings" -d "Manage global quickchr settings"
|
|
493
496
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
494
497
|
-a "setup" -d "Interactive setup wizard"
|
|
495
498
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
package/src/lib/disk.ts
CHANGED
|
@@ -260,8 +260,11 @@ export function parseSnapshotList(monitorOutput: string): SnapshotInfo[] {
|
|
|
260
260
|
// Match snapshot data lines: ID, TAG, VM SIZE, DATE (YYYY-MM-DD HH:MM:SS), VM CLOCK, ICOUNT
|
|
261
261
|
// Example: "-- test 113 MiB 2026-04-14 00:47:19 0000:01:17.163 --"
|
|
262
262
|
// Example: "1 snap1 113 MiB 2026-04-14 00:47:19 0000:01:17.163 --"
|
|
263
|
+
// VM CLOCK hour width varies by QEMU version (2-digit on 8.x runners,
|
|
264
|
+
// 4-digit on 11.x brew builds) and the ICOUNT column may be absent —
|
|
265
|
+
// accept both so list() doesn't silently return [] on older QEMU.
|
|
263
266
|
const match = line.match(
|
|
264
|
-
/^\s*(\S+)\s+(\S+)\s+(\d+(?:\.\d+)?\s*(?:[KMGT]i?B|bytes?))\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+(\d{4}:\d{2}:\d{2}\.\d+)
|
|
267
|
+
/^\s*(\S+)\s+(\S+)\s+(\d+(?:\.\d+)?\s*(?:[KMGT]i?B|bytes?))\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s+(\d{2,4}:\d{2}:\d{2}\.\d+)(?:\s+(\S+))?/i,
|
|
265
268
|
);
|
|
266
269
|
if (!match) continue;
|
|
267
270
|
|
|
@@ -274,7 +277,7 @@ export function parseSnapshotList(monitorOutput: string): SnapshotInfo[] {
|
|
|
274
277
|
vmStateSize: parseVmSize(vmSizeStr),
|
|
275
278
|
date: dateStr.replace(" ", "T") + "Z",
|
|
276
279
|
vmClock: vmClock,
|
|
277
|
-
icount: icountStr
|
|
280
|
+
icount: icountStr && icountStr !== "--" ? Number(icountStr) : undefined,
|
|
278
281
|
});
|
|
279
282
|
}
|
|
280
283
|
|
package/src/lib/forward-spec.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Parse a CLI `--forward` spec into
|
|
2
|
+
* Parse a CLI `--forward` spec into one or more `PortMapping`s.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Two grammars share this file:
|
|
5
|
+
*
|
|
6
|
+
* **Single port** — `parseForwardSpec`, grammar `name[:host[:guest]][/proto]`:
|
|
5
7
|
*
|
|
6
8
|
* - `name` — required. If it matches a key/alias in `WELL_KNOWN_GUEST_PORTS`,
|
|
7
9
|
* missing `guest` and `proto` are filled from the registry. The original
|
|
@@ -14,7 +16,16 @@
|
|
|
14
16
|
* - `proto` — optional. Defaults from registry, else `tcp`. Must be `tcp` or
|
|
15
17
|
* `udp`.
|
|
16
18
|
*
|
|
17
|
-
*
|
|
19
|
+
* **Port range** — `expandForwardSpec`, grammar
|
|
20
|
+
* `name:hostStart-hostEnd[:guestStart-guestEnd][/proto]`. Expands to one
|
|
21
|
+
* `PortMapping` per port (QEMU `hostfwd` has no native range — each port needs
|
|
22
|
+
* its own directive). Useful for L3 peer protocols with dynamic data ports
|
|
23
|
+
* (e.g. RouterOS bandwidth-test allocates UDP ports at runtime). See the rules
|
|
24
|
+
* on `expandForwardSpec` below. `expandForwardSpec` is the range-aware entry
|
|
25
|
+
* point and delegates to `parseForwardSpec` for non-range specs, so callers can
|
|
26
|
+
* always use `expandForwardSpec` (the CLI does).
|
|
27
|
+
*
|
|
28
|
+
* Both throw `QuickCHRError("INVALID_FORWARD_SPEC", ...)` with the original spec
|
|
18
29
|
* embedded in the message on any parse error.
|
|
19
30
|
*/
|
|
20
31
|
|
|
@@ -90,3 +101,129 @@ export function parseForwardSpec(spec: string): PortMapping {
|
|
|
90
101
|
|
|
91
102
|
return { name, host, guest, proto };
|
|
92
103
|
}
|
|
104
|
+
|
|
105
|
+
/** Max ports a single range `--forward` may span, bounding the generated
|
|
106
|
+
* `hostfwd` string (one directive per port). */
|
|
107
|
+
export const FORWARD_RANGE_MAX = 64;
|
|
108
|
+
|
|
109
|
+
function parseRange(
|
|
110
|
+
spec: string,
|
|
111
|
+
raw: string,
|
|
112
|
+
label: string,
|
|
113
|
+
): { start: number; end: number } {
|
|
114
|
+
const dash = raw.indexOf("-");
|
|
115
|
+
const startRaw = raw.slice(0, dash);
|
|
116
|
+
const endRaw = raw.slice(dash + 1);
|
|
117
|
+
const start = parsePort(spec, startRaw, `${label} range start`, false);
|
|
118
|
+
const end = parsePort(spec, endRaw, `${label} range end`, false);
|
|
119
|
+
if (end < start) {
|
|
120
|
+
fail(spec, `${label} range end ${end} is below start ${start}`);
|
|
121
|
+
}
|
|
122
|
+
return { start, end };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Range-aware front door for `--forward` specs. Returns a `PortMapping[]`.
|
|
127
|
+
*
|
|
128
|
+
* For a single-port spec (no `-` in the host/guest segment) this returns
|
|
129
|
+
* `[parseForwardSpec(spec)]` unchanged. For a range spec
|
|
130
|
+
* `name:hostStart-hostEnd[:guestStart-guestEnd][/proto]` it expands to one
|
|
131
|
+
* `PortMapping` per port, named `\`${name}-${hostPort}\`` so each is a distinct
|
|
132
|
+
* key in the machine's port map.
|
|
133
|
+
*
|
|
134
|
+
* Range rules:
|
|
135
|
+
* - **Host range is required** — ranges are not auto-allocated. The per-instance
|
|
136
|
+
* port block (10 ports) can't guarantee a contiguous run, so range host ports
|
|
137
|
+
* must be explicit. They are then collision-checked like any explicit
|
|
138
|
+
* `extraPorts` host (`validateExplicitExtraPorts`).
|
|
139
|
+
* - **Guest range is optional** — defaults to the same numbers as the host
|
|
140
|
+
* range. When given it must be the **same length** as the host range.
|
|
141
|
+
* - **`proto`** defaults from the `WELL_KNOWN_GUEST_PORTS` registry (by `name`),
|
|
142
|
+
* else `tcp`; an explicit `/proto` suffix wins.
|
|
143
|
+
* - Reversed ranges (end < start) and spans over {@link FORWARD_RANGE_MAX} ports
|
|
144
|
+
* throw `INVALID_FORWARD_SPEC`.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* expandForwardSpec("btest:9200-9202:2000-2002/udp");
|
|
148
|
+
* // → [{name:"btest-9200",host:9200,guest:2000,proto:"udp"}, … 9201/2001, 9202/2002]
|
|
149
|
+
* @example
|
|
150
|
+
* expandForwardSpec("smb"); // single port → [{name:"smb",host:0,guest:445,proto:"tcp"}]
|
|
151
|
+
*/
|
|
152
|
+
export function expandForwardSpec(spec: string): PortMapping[] {
|
|
153
|
+
if (!spec?.trim()) {
|
|
154
|
+
throw new QuickCHRError("INVALID_FORWARD_SPEC", "Empty --forward spec");
|
|
155
|
+
}
|
|
156
|
+
const trimmed = spec.trim();
|
|
157
|
+
|
|
158
|
+
// Split off /proto suffix (so a range's "-" can't be confused with anything).
|
|
159
|
+
let body = trimmed;
|
|
160
|
+
let protoStr: string | undefined;
|
|
161
|
+
const slash = trimmed.lastIndexOf("/");
|
|
162
|
+
if (slash !== -1) {
|
|
163
|
+
body = trimmed.slice(0, slash);
|
|
164
|
+
protoStr = trimmed.slice(slash + 1).toLowerCase();
|
|
165
|
+
if (protoStr !== "tcp" && protoStr !== "udp") {
|
|
166
|
+
fail(spec, `protocol must be "tcp" or "udp", got "${protoStr}"`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const parts = body.split(":");
|
|
171
|
+
const hasRange = Boolean(parts[1]?.includes("-") || parts[2]?.includes("-"));
|
|
172
|
+
if (!hasRange) {
|
|
173
|
+
return [parseForwardSpec(spec)];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (parts.length > 3) {
|
|
177
|
+
fail(spec, `too many ":" segments (expected name:hostStart-hostEnd[:guestStart-guestEnd])`);
|
|
178
|
+
}
|
|
179
|
+
const name = parts[0]?.trim() ?? "";
|
|
180
|
+
if (!name) {
|
|
181
|
+
fail(spec, "name is required");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const hostSegment = parts[1];
|
|
185
|
+
if (!hostSegment?.includes("-")) {
|
|
186
|
+
fail(
|
|
187
|
+
spec,
|
|
188
|
+
"a range --forward requires an explicit host range (e.g. 9200-9210); ranges are not auto-allocated",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const hostRange = parseRange(spec, hostSegment, "host");
|
|
192
|
+
|
|
193
|
+
const guestSegment = parts[2];
|
|
194
|
+
let guestRange: { start: number; end: number };
|
|
195
|
+
if (guestSegment !== undefined && guestSegment !== "") {
|
|
196
|
+
if (!guestSegment.includes("-")) {
|
|
197
|
+
fail(
|
|
198
|
+
spec,
|
|
199
|
+
`guest segment "${guestSegment}" must be a range matching the host range (e.g. 2000-2010), not a single port`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
guestRange = parseRange(spec, guestSegment, "guest");
|
|
203
|
+
} else {
|
|
204
|
+
// guest range omitted → default to the host range's port numbers
|
|
205
|
+
guestRange = { start: hostRange.start, end: hostRange.end };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const hostCount = hostRange.end - hostRange.start + 1;
|
|
209
|
+
const guestCount = guestRange.end - guestRange.start + 1;
|
|
210
|
+
if (hostCount !== guestCount) {
|
|
211
|
+
fail(
|
|
212
|
+
spec,
|
|
213
|
+
`host range (${hostCount} ports) and guest range (${guestCount} ports) must be the same length`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (hostCount > FORWARD_RANGE_MAX) {
|
|
217
|
+
fail(spec, `range spans ${hostCount} ports, exceeding the ${FORWARD_RANGE_MAX}-port cap`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const known = lookupGuestPort(name);
|
|
221
|
+
const proto: "tcp" | "udp" = (protoStr as "tcp" | "udp" | undefined) ?? known?.proto ?? "tcp";
|
|
222
|
+
|
|
223
|
+
const mappings: PortMapping[] = [];
|
|
224
|
+
for (let i = 0; i < hostCount; i++) {
|
|
225
|
+
const host = hostRange.start + i;
|
|
226
|
+
mappings.push({ name: `${name}-${host}`, host, guest: guestRange.start + i, proto });
|
|
227
|
+
}
|
|
228
|
+
return mappings;
|
|
229
|
+
}
|
package/src/lib/net.ts
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resilient HTTP fetch for MikroTik's download hosts.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* Primary path: a plain `fetch` on the system resolver. This is tried first,
|
|
5
|
+
* accepting the latency cost when it fails — usually small, but up to the
|
|
6
|
+
* resolver's own failure time (the broken CI stub below took 2–26 s). It is
|
|
7
|
+
* dual-stack (happy eyeballs) and on par with curl/most tools, and it honors
|
|
8
|
+
* local DNS configuration —
|
|
9
|
+
* `/etc/hosts` pins, VPN/split-horizon DNS, mirror redirects, IPv6-only egress —
|
|
10
|
+
* which a forced public-DNS+IPv4 path would silently override and could itself
|
|
11
|
+
* turn into a fresh failure mode. So we stay "normal" unless the normal path
|
|
12
|
+
* actually breaks.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* Fallback: some environments (notably GitHub-hosted CI runners, observed
|
|
15
|
+
* 2026-06) have a broken/slow stub resolver that returns `ESERVFAIL` — slowly,
|
|
16
|
+
* 2 to 26 s — for `upgrade.mikrotik.com` / `download.mikrotik.com`, or hands back
|
|
17
|
+
* only an unreachable AAAA, surfacing as Bun's `errno: 0` ConnectionRefused /
|
|
18
|
+
* FailedToOpenSocket. When the normal fetch fails *that* way (a connection-class
|
|
19
|
+
* error), we retry by resolving the A record against a public DNS server
|
|
20
|
+
* directly (~10 ms), bypassing the host's resolv.conf, and connecting to the
|
|
21
|
+
* IPv4 literal with the `Host` header and TLS SNI preserved so certificate
|
|
22
|
+
* validation still passes.
|
|
23
|
+
*
|
|
24
|
+
* Only connection-class failures trigger the fallback; HTTP responses (incl.
|
|
25
|
+
* 5xx) and aborts (`AbortError`, e.g. from `AbortSignal.timeout`) pass through
|
|
26
|
+
* unchanged and are never retried. (A low-level connect `ETIMEDOUT` is a
|
|
27
|
+
* connection-class failure and *is* retried — see `isConnectionFailure`.)
|
|
20
28
|
*/
|
|
21
29
|
|
|
22
30
|
import { promises as dns } from "node:dns";
|
|
@@ -28,13 +36,14 @@ const DNS_TIMEOUT_MS = 3000;
|
|
|
28
36
|
* True for the connection-class failures raised when a socket cannot be opened
|
|
29
37
|
* (e.g. IPv4 blocked on an IPv6-only network, or Bun's `errno: 0`
|
|
30
38
|
* ConnectionRefused / FailedToOpenSocket against an unreachable address), plus
|
|
31
|
-
* the standard connect errors. Excludes aborts
|
|
32
|
-
* (those carry a Response and never throw
|
|
33
|
-
*
|
|
39
|
+
* the standard connect errors. Excludes `AbortError` (aborts/`AbortSignal`
|
|
40
|
+
* timeouts) and HTTP-level outcomes (those carry a Response and never throw
|
|
41
|
+
* here). Used to decide whether to fall back from the normal fetch to the
|
|
42
|
+
* public-DNS IPv4 attempt.
|
|
34
43
|
*/
|
|
35
44
|
export function isConnectionFailure(err: unknown): boolean {
|
|
36
45
|
if (!err || typeof err !== "object") return false;
|
|
37
|
-
const e = err as { name?: string; code?: string; cause?: { code?: string } };
|
|
46
|
+
const e = err as { name?: string; code?: string; errno?: number; cause?: { code?: string } };
|
|
38
47
|
if (e.name === "AbortError") return false;
|
|
39
48
|
const code = e.code ?? e.cause?.code;
|
|
40
49
|
if (
|
|
@@ -49,10 +58,12 @@ export function isConnectionFailure(err: unknown): boolean {
|
|
|
49
58
|
) {
|
|
50
59
|
return true;
|
|
51
60
|
}
|
|
52
|
-
// Bun
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
61
|
+
// Bun has also surfaced low-level connect failures as a `TypeError` carrying
|
|
62
|
+
// `errno: 0` (its connect-failure marker; the typed `code`s above catch the
|
|
63
|
+
// current form — Bun 1.3 throws an `Error` with `code: "ConnectionRefused"`,
|
|
64
|
+
// confirmed by probe). Gate on that marker so an unrelated `TypeError` (a real
|
|
65
|
+
// bug) surfaces immediately instead of triggering a pointless DNS retry.
|
|
66
|
+
return e.name === "TypeError" && e.errno === 0;
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
/** Rewrite a URL to target an explicit IPv4 literal, preserving port/path/query. */
|
|
@@ -64,10 +75,11 @@ export function toIpv4Url(url: string, address: string): string {
|
|
|
64
75
|
|
|
65
76
|
/**
|
|
66
77
|
* Resolve a host's A record via public DNS servers directly, bypassing the
|
|
67
|
-
* host's resolv.conf
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
78
|
+
* host's resolv.conf. Used only as the fallback path, after a normal fetch has
|
|
79
|
+
* already failed with a connection-class error (e.g. a broken stub resolver on
|
|
80
|
+
* CI runners). Returns undefined — never throws — when public DNS is unreachable
|
|
81
|
+
* or has no answer, so the caller can surface the original failure. Bounded by
|
|
82
|
+
* DNS_TIMEOUT_MS so a blocked resolver does not stall the request.
|
|
71
83
|
*/
|
|
72
84
|
async function resolveIpv4(host: string): Promise<string | undefined> {
|
|
73
85
|
const resolver = new dns.Resolver();
|
|
@@ -88,6 +100,12 @@ async function resolveIpv4(host: string): Promise<string | undefined> {
|
|
|
88
100
|
}
|
|
89
101
|
}
|
|
90
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Fetch `url` but connect to an explicit IPv4 `address`, keeping HTTP and TLS
|
|
105
|
+
* pointed at the original host: the `Host` header and TLS SNI (`serverName`) are
|
|
106
|
+
* set to the URL's hostname so virtual-hosting routing and certificate
|
|
107
|
+
* validation still pass against the IP literal. The failback transport.
|
|
108
|
+
*/
|
|
91
109
|
async function fetchOverIpv4(
|
|
92
110
|
url: string,
|
|
93
111
|
address: string,
|
|
@@ -99,25 +117,31 @@ async function fetchOverIpv4(
|
|
|
99
117
|
return fetch(toIpv4Url(url, address), {
|
|
100
118
|
...init,
|
|
101
119
|
headers,
|
|
102
|
-
|
|
120
|
+
// serverName last: it must pin to the real host for cert validation against
|
|
121
|
+
// the IP literal — a caller-provided init.tls.serverName must not override it.
|
|
122
|
+
tls: { ...init?.tls, serverName: host },
|
|
103
123
|
});
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
/**
|
|
107
|
-
* `fetch()` that
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
127
|
+
* `fetch()` that tries the system resolver first (a normal dual-stack fetch)
|
|
128
|
+
* and, only when that fails with a connection-class error, falls back to
|
|
129
|
+
* resolving via public DNS and connecting over the IPv4 literal — so it survives
|
|
130
|
+
* a broken/slow system resolver (e.g. GitHub-hosted CI runners) without
|
|
131
|
+
* overriding local DNS in the common case. Behaves like `fetch` for HTTP
|
|
132
|
+
* responses and aborts.
|
|
111
133
|
*/
|
|
112
134
|
export async function fetchResilient(url: string, init?: BunFetchRequestInit): Promise<Response> {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
try {
|
|
136
|
+
return await fetch(url, init);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// Only a connection-class failure (e.g. a broken/slow stub resolver, or an
|
|
139
|
+
// unreachable AAAA) is worth the public-DNS workaround. Aborts (AbortError)
|
|
140
|
+
// and everything else propagate unchanged.
|
|
141
|
+
if (!isConnectionFailure(err)) throw err;
|
|
142
|
+
const address = await resolveIpv4(new URL(url).hostname);
|
|
143
|
+
// Public DNS can't help (unreachable / no answer) — surface the original failure.
|
|
144
|
+
if (address === undefined) throw err;
|
|
145
|
+
return fetchOverIpv4(url, address, init);
|
|
121
146
|
}
|
|
122
|
-
return fetch(url, init);
|
|
123
147
|
}
|
package/src/lib/paths.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared filesystem paths for quickchr's config-tier (XDG_CONFIG-like) files.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
/** ~/.config/quickchr — used by secrets.ts (credential fallback files) and
|
|
9
|
+
* settings.ts (quickchr.env). Single source of truth so those modules never
|
|
10
|
+
* drift on the HOME/USERPROFILE fallback chain. */
|
|
11
|
+
export function quickchrConfigDir(): string {
|
|
12
|
+
return join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), ".config", "quickchr");
|
|
13
|
+
}
|