@tikoci/quickchr 0.2.0 → 0.3.0
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/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +182 -4
- package/src/index.ts +8 -1
- package/src/lib/auth.ts +21 -0
- package/src/lib/cache.ts +251 -0
- package/src/lib/forward-spec.ts +92 -0
- package/src/lib/guest-ports.ts +67 -0
- package/src/lib/network.ts +73 -0
- package/src/lib/packages.ts +8 -55
- package/src/lib/quickchr.ts +83 -5
- package/src/lib/scp.ts +97 -0
- package/src/lib/socket-registry.ts +23 -4
- package/src/lib/types.ts +82 -4
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a CLI `--forward` spec into a `PortMapping`.
|
|
3
|
+
*
|
|
4
|
+
* Grammar: `name[:host[:guest]][/proto]`
|
|
5
|
+
*
|
|
6
|
+
* - `name` — required. If it matches a key/alias in `WELL_KNOWN_GUEST_PORTS`,
|
|
7
|
+
* missing `guest` and `proto` are filled from the registry. The original
|
|
8
|
+
* spelling (alias or canonical) is preserved as `name` so users see the
|
|
9
|
+
* label they typed in `quickchr list` / state.
|
|
10
|
+
* - `host` — optional. When omitted (or 0), `buildPortMappings` auto-allocates
|
|
11
|
+
* a host port from the machine's port block.
|
|
12
|
+
* - `guest` — optional only when `name` resolves in the registry; otherwise
|
|
13
|
+
* required.
|
|
14
|
+
* - `proto` — optional. Defaults from registry, else `tcp`. Must be `tcp` or
|
|
15
|
+
* `udp`.
|
|
16
|
+
*
|
|
17
|
+
* Throws `QuickCHRError("INVALID_FORWARD_SPEC", ...)` with the original spec
|
|
18
|
+
* embedded in the message on any parse error.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { lookupGuestPort } from "./guest-ports.ts";
|
|
22
|
+
import { type PortMapping, QuickCHRError } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
function fail(spec: string, reason: string): never {
|
|
25
|
+
throw new QuickCHRError(
|
|
26
|
+
"INVALID_FORWARD_SPEC",
|
|
27
|
+
`Invalid --forward spec "${spec}": ${reason}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parsePort(spec: string, raw: string, label: string, allowZero: boolean): number {
|
|
32
|
+
if (!/^\d+$/.test(raw)) {
|
|
33
|
+
fail(spec, `${label} port "${raw}" is not a number`);
|
|
34
|
+
}
|
|
35
|
+
const n = Number(raw);
|
|
36
|
+
const min = allowZero ? 0 : 1;
|
|
37
|
+
if (n < min || n > 65535) {
|
|
38
|
+
fail(spec, `${label} port ${n} out of range (${min}-65535)`);
|
|
39
|
+
}
|
|
40
|
+
return n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseForwardSpec(spec: string): PortMapping {
|
|
44
|
+
if (!spec?.trim()) {
|
|
45
|
+
throw new QuickCHRError(
|
|
46
|
+
"INVALID_FORWARD_SPEC",
|
|
47
|
+
"Empty --forward spec",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const trimmed = spec.trim();
|
|
51
|
+
|
|
52
|
+
// Split off /proto suffix
|
|
53
|
+
let body = trimmed;
|
|
54
|
+
let protoStr: string | undefined;
|
|
55
|
+
const slash = trimmed.lastIndexOf("/");
|
|
56
|
+
if (slash !== -1) {
|
|
57
|
+
body = trimmed.slice(0, slash);
|
|
58
|
+
protoStr = trimmed.slice(slash + 1).toLowerCase();
|
|
59
|
+
if (protoStr !== "tcp" && protoStr !== "udp") {
|
|
60
|
+
fail(spec, `protocol must be "tcp" or "udp", got "${protoStr}"`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const parts = body.split(":");
|
|
65
|
+
if (parts.length > 3) {
|
|
66
|
+
fail(spec, `too many ":" segments (expected name[:host[:guest]])`);
|
|
67
|
+
}
|
|
68
|
+
const name = parts[0]?.trim() ?? "";
|
|
69
|
+
if (!name) {
|
|
70
|
+
fail(spec, "name is required");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const known = lookupGuestPort(name);
|
|
74
|
+
|
|
75
|
+
let host = 0;
|
|
76
|
+
if (parts.length >= 2 && parts[1] !== "" && parts[1] !== undefined) {
|
|
77
|
+
host = parsePort(spec, parts[1], "host", true);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let guest: number;
|
|
81
|
+
if (parts.length >= 3 && parts[2] !== "" && parts[2] !== undefined) {
|
|
82
|
+
guest = parsePort(spec, parts[2], "guest", false);
|
|
83
|
+
} else if (known) {
|
|
84
|
+
guest = known.guest;
|
|
85
|
+
} else {
|
|
86
|
+
fail(spec, `guest port required for unknown service "${name}"`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const proto: "tcp" | "udp" = (protoStr as "tcp" | "udp" | undefined) ?? known?.proto ?? "tcp";
|
|
90
|
+
|
|
91
|
+
return { name, host, guest, proto };
|
|
92
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Well-known guest port registry.
|
|
3
|
+
*
|
|
4
|
+
* Maps a canonical service name (e.g. "smb", "winbox") to its guest-side port
|
|
5
|
+
* and protocol. Used to power short-form `--forward <name>` CLI flags so users
|
|
6
|
+
* don't have to remember exact port numbers.
|
|
7
|
+
*
|
|
8
|
+
* Pure data + lookup. No side effects, no imports from the rest of the codebase.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface GuestPortDef {
|
|
12
|
+
/** Canonical lowercase name, e.g. "smb". */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Guest-side port number (1..65535). */
|
|
15
|
+
guest: number;
|
|
16
|
+
/** Transport protocol. */
|
|
17
|
+
proto: "tcp" | "udp";
|
|
18
|
+
/** Optional alternate names that resolve to this entry, e.g. ["cifs"] for "smb". */
|
|
19
|
+
aliases?: string[];
|
|
20
|
+
/** Brief, factual notes. May reference RouterOS package or subsystem. */
|
|
21
|
+
notes?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const WELL_KNOWN_GUEST_PORTS: readonly GuestPortDef[] = [
|
|
25
|
+
// --- RouterOS services (defaults from /ip/service) ---
|
|
26
|
+
{ name: "ftp", guest: 21, proto: "tcp", notes: "RouterOS /ip/service ftp" },
|
|
27
|
+
{ name: "ssh", guest: 22, proto: "tcp", notes: "RouterOS /ip/service ssh" },
|
|
28
|
+
{ name: "telnet", guest: 23, proto: "tcp", notes: "RouterOS /ip/service telnet" },
|
|
29
|
+
{ name: "http", guest: 80, proto: "tcp", aliases: ["www", "webfig"], notes: "RouterOS /ip/service www (WebFig + REST)" },
|
|
30
|
+
{ name: "https", guest: 443, proto: "tcp", aliases: ["www-ssl", "webfig-ssl"], notes: "RouterOS /ip/service www-ssl (WebFig + REST over TLS)" },
|
|
31
|
+
{ name: "snmp", guest: 161, proto: "udp", notes: "RouterOS /snmp" },
|
|
32
|
+
{ name: "bandwidth-test", guest: 2000, proto: "tcp", aliases: ["btest"], notes: "RouterOS /tool/bandwidth-server" },
|
|
33
|
+
{ name: "dude", guest: 2210, proto: "tcp", notes: "RouterOS dude package server (legacy)" },
|
|
34
|
+
{ name: "dude-secure", guest: 2211, proto: "tcp", notes: "RouterOS dude package server (TLS)" },
|
|
35
|
+
{ name: "upnp", guest: 2828, proto: "tcp", notes: "RouterOS /ip/upnp" },
|
|
36
|
+
{ name: "winbox", guest: 8291, proto: "tcp", notes: "RouterOS /ip/service winbox (also used by Dude client and MikroTik mobile app)" },
|
|
37
|
+
{ name: "api", guest: 8728, proto: "tcp", notes: "RouterOS /ip/service api (native API)" },
|
|
38
|
+
{ name: "api-ssl", guest: 8729, proto: "tcp", notes: "RouterOS /ip/service api-ssl" },
|
|
39
|
+
{ name: "socks", guest: 1080, proto: "tcp", notes: "RouterOS /ip/socks" },
|
|
40
|
+
|
|
41
|
+
// --- Common services often forwarded for container / userspace use ---
|
|
42
|
+
{ name: "dns", guest: 53, proto: "udp", notes: "DNS resolver (UDP)" },
|
|
43
|
+
{ name: "dns-tcp", guest: 53, proto: "tcp", notes: "DNS resolver (TCP, used for large responses and zone transfers)" },
|
|
44
|
+
{ name: "ntp", guest: 123, proto: "udp", notes: "Network Time Protocol" },
|
|
45
|
+
{ name: "smb", guest: 445, proto: "tcp", aliases: ["cifs"], notes: "SMB/CIFS file sharing" },
|
|
46
|
+
{ name: "syslog", guest: 514, proto: "udp", notes: "Syslog (UDP)" },
|
|
47
|
+
{ name: "mqtt", guest: 1883, proto: "tcp", notes: "MQTT broker (plaintext)" },
|
|
48
|
+
{ name: "winrm", guest: 5985, proto: "tcp", notes: "Windows Remote Management (HTTP)" },
|
|
49
|
+
{ name: "winrm-https", guest: 5986, proto: "tcp", notes: "Windows Remote Management (HTTPS)" },
|
|
50
|
+
{ name: "http-alt", guest: 8080, proto: "tcp", notes: "Alternate HTTP, common for containerized web apps" },
|
|
51
|
+
{ name: "https-alt", guest: 8443, proto: "tcp", notes: "Alternate HTTPS, common for containerized web apps" },
|
|
52
|
+
{ name: "mqtts", guest: 8883, proto: "tcp", notes: "MQTT broker (TLS)" },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Look up a guest port definition by canonical name or alias.
|
|
57
|
+
* Case-insensitive. Returns `undefined` if not found.
|
|
58
|
+
*/
|
|
59
|
+
export function lookupGuestPort(nameOrAlias: string): GuestPortDef | undefined {
|
|
60
|
+
if (!nameOrAlias) return undefined;
|
|
61
|
+
const key = nameOrAlias.toLowerCase();
|
|
62
|
+
for (const entry of WELL_KNOWN_GUEST_PORTS) {
|
|
63
|
+
if (entry.name === key) return entry;
|
|
64
|
+
if (entry.aliases?.some((a) => a.toLowerCase() === key)) return entry;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
package/src/lib/network.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DEFAULT_PORT_BASE,
|
|
9
9
|
PORTS_PER_BLOCK,
|
|
10
10
|
type ChrPorts,
|
|
11
|
+
type MachineState,
|
|
11
12
|
type NetworkConfig,
|
|
12
13
|
type NetworkMode,
|
|
13
14
|
type NetworkSpecifier,
|
|
@@ -77,6 +78,78 @@ export function buildPortMappings(
|
|
|
77
78
|
return mappings;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
/** Validate user-supplied `extraPorts` with explicit `host` values for collisions.
|
|
82
|
+
*
|
|
83
|
+
* Rejects three classes of conflict:
|
|
84
|
+
* 1. Explicit host equals one of this machine's auto-allocated SERVICE_PORTS
|
|
85
|
+
* slots (`portBase + offset`) for a non-excluded service.
|
|
86
|
+
* 2. Two explicit entries in the same `extraPorts` set request the same host.
|
|
87
|
+
* 3. Explicit host is already in use by another existing machine's port mapping.
|
|
88
|
+
*
|
|
89
|
+
* Auto-allocated extras (host falsy/0) are not validated here — the live
|
|
90
|
+
* `findAvailablePortBlock` probe handles those.
|
|
91
|
+
*
|
|
92
|
+
* Pass `ownMachineName` to skip an existing machine's mappings (e.g. when
|
|
93
|
+
* re-validating during a restart).
|
|
94
|
+
*/
|
|
95
|
+
export function validateExplicitExtraPorts(
|
|
96
|
+
extraPorts: PortMapping[] | undefined,
|
|
97
|
+
portBase: number,
|
|
98
|
+
excludePorts: ServiceName[] = [],
|
|
99
|
+
existingMachines: MachineState[] = [],
|
|
100
|
+
ownMachineName?: string,
|
|
101
|
+
): void {
|
|
102
|
+
if (!extraPorts || extraPorts.length === 0) return;
|
|
103
|
+
|
|
104
|
+
const serviceHosts = new Map<number, string>();
|
|
105
|
+
for (const [name, spec] of Object.entries(SERVICE_PORTS)) {
|
|
106
|
+
if (excludePorts.includes(name as ServiceName)) continue;
|
|
107
|
+
serviceHosts.set(portBase + spec.offset, name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const otherHosts = new Map<number, { machine: string; service: string }>();
|
|
111
|
+
for (const m of existingMachines) {
|
|
112
|
+
if (ownMachineName && m.name === ownMachineName) continue;
|
|
113
|
+
for (const mapping of Object.values(m.ports ?? {})) {
|
|
114
|
+
otherHosts.set(mapping.host, { machine: m.name, service: mapping.name });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const seenInExtras = new Map<number, string>();
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < extraPorts.length; i++) {
|
|
121
|
+
const extra = extraPorts[i];
|
|
122
|
+
if (!extra?.host) continue;
|
|
123
|
+
|
|
124
|
+
const label = extra.name || `custom-${i}`;
|
|
125
|
+
|
|
126
|
+
const svc = serviceHosts.get(extra.host);
|
|
127
|
+
if (svc) {
|
|
128
|
+
throw new QuickCHRError(
|
|
129
|
+
"PORT_CONFLICT",
|
|
130
|
+
`Explicit extra port "${label}" host=${extra.host} collides with built-in service "${svc}" (portBase=${portBase}). Pick a different host port or exclude the service.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const dup = seenInExtras.get(extra.host);
|
|
135
|
+
if (dup) {
|
|
136
|
+
throw new QuickCHRError(
|
|
137
|
+
"PORT_CONFLICT",
|
|
138
|
+
`Explicit extra ports "${dup}" and "${label}" both request host=${extra.host}.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
seenInExtras.set(extra.host, label);
|
|
142
|
+
|
|
143
|
+
const other = otherHosts.get(extra.host);
|
|
144
|
+
if (other) {
|
|
145
|
+
throw new QuickCHRError(
|
|
146
|
+
"PORT_CONFLICT",
|
|
147
|
+
`Explicit extra port "${label}" host=${extra.host} is already allocated to machine "${other.machine}" (service "${other.service}").`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
80
153
|
/** Build QEMU hostfwd string from port mappings. */
|
|
81
154
|
export function buildHostfwdString(ports: Record<string, PortMapping>): string {
|
|
82
155
|
return Object.values(ports)
|
package/src/lib/packages.ts
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
* Extra package download and installation for CHR instances.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync, readdirSync
|
|
5
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
|
-
import { tmpdir } from "node:os";
|
|
8
7
|
import type { Arch } from "./types.ts";
|
|
9
8
|
import { QuickCHRError } from "./types.ts";
|
|
10
9
|
import { packagesDownloadUrl } from "./versions.ts";
|
|
11
10
|
import { getCacheDir, ensureDir } from "./state.ts";
|
|
12
11
|
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
13
12
|
import { restPost } from "./rest.ts";
|
|
13
|
+
import { scpPush } from "./scp.ts";
|
|
14
14
|
import { extractZip } from "./zip.ts";
|
|
15
15
|
|
|
16
16
|
/** Download and extract the all-packages ZIP for a version/arch. Returns the extract dir. */
|
|
@@ -74,7 +74,7 @@ export function findPackageFile(
|
|
|
74
74
|
return match ? join(extractDir, match) : undefined;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/** Upload packages to a running CHR via SCP. */
|
|
77
|
+
/** Upload packages to a running CHR via SCP. Uploads each file to flash root (`/<basename>`). */
|
|
78
78
|
export async function uploadPackages(
|
|
79
79
|
packagePaths: string[],
|
|
80
80
|
sshPort: number,
|
|
@@ -82,58 +82,11 @@ export async function uploadPackages(
|
|
|
82
82
|
password: string = "",
|
|
83
83
|
logger?: ProgressLogger,
|
|
84
84
|
): Promise<void> {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const isWindows = process.platform === "win32";
|
|
91
|
-
const ext = isWindows ? ".cmd" : ".sh";
|
|
92
|
-
const askpassPath = join(tmpdir(), `quickchr-askpass-${process.pid}${ext}`);
|
|
93
|
-
if (isWindows) {
|
|
94
|
-
// Windows .cmd: OpenSSH reads stdout from the helper
|
|
95
|
-
await Bun.write(askpassPath, `@echo off\r\necho ${password.replace(/[&|<>^%]/g, "^$&")}\r\n`);
|
|
96
|
-
} else {
|
|
97
|
-
await Bun.write(askpassPath, `#!/bin/sh\nprintf '%s' '${password.replace(/'/g, "'\\''")}'`);
|
|
98
|
-
chmodSync(askpassPath, 0o755);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const scpEnv: Record<string, string> = {
|
|
102
|
-
...(process.env as Record<string, string>),
|
|
103
|
-
DISPLAY: "",
|
|
104
|
-
SSH_ASKPASS: askpassPath,
|
|
105
|
-
SSH_ASKPASS_REQUIRE: "prefer",
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
const log = logger ?? createLogger();
|
|
110
|
-
for (const pkgPath of packagePaths) {
|
|
111
|
-
const filename = basename(pkgPath);
|
|
112
|
-
log.status(`Uploading: ${filename}`);
|
|
113
|
-
|
|
114
|
-
const scpArgs = [
|
|
115
|
-
"scp", "-P", String(sshPort),
|
|
116
|
-
"-o", "StrictHostKeyChecking=accept-new",
|
|
117
|
-
"-o", "UserKnownHostsFile=/dev/null",
|
|
118
|
-
pkgPath, `${user}@127.0.0.1:/`,
|
|
119
|
-
];
|
|
120
|
-
|
|
121
|
-
const result = Bun.spawnSync(scpArgs, {
|
|
122
|
-
stdout: "pipe",
|
|
123
|
-
stderr: "pipe",
|
|
124
|
-
env: scpEnv,
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
if (result.exitCode !== 0) {
|
|
128
|
-
const stderr = new TextDecoder().decode(result.stderr);
|
|
129
|
-
throw new QuickCHRError(
|
|
130
|
-
"PROCESS_FAILED",
|
|
131
|
-
`SCP upload failed for ${filename}: ${stderr}`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
} finally {
|
|
136
|
-
try { unlinkSync(askpassPath); } catch { /* ignore */ }
|
|
85
|
+
const log = logger ?? createLogger();
|
|
86
|
+
for (const pkgPath of packagePaths) {
|
|
87
|
+
const filename = basename(pkgPath);
|
|
88
|
+
log.status(`Uploading: ${filename}`);
|
|
89
|
+
await scpPush(pkgPath, undefined, { sshPort, user, password });
|
|
137
90
|
}
|
|
138
91
|
}
|
|
139
92
|
|
package/src/lib/quickchr.ts
CHANGED
|
@@ -27,11 +27,12 @@ import {
|
|
|
27
27
|
assertProvisioningSupportedVersion,
|
|
28
28
|
PROVISIONING_FEATURE_LABEL,
|
|
29
29
|
} from "./versions.ts";
|
|
30
|
-
import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, buildHostfwdString, hasUserModeNetwork } from "./network.ts";
|
|
30
|
+
import { buildPortMappings, findAvailablePortBlock, resolveStartNetworks, resolveAllNetworks, buildHostfwdString, hasUserModeNetwork, validateExplicitExtraPorts } from "./network.ts";
|
|
31
31
|
import {
|
|
32
32
|
getUsedPortBases,
|
|
33
33
|
saveMachine,
|
|
34
34
|
loadMachine,
|
|
35
|
+
loadAllMachines,
|
|
35
36
|
removeMachine as removeState,
|
|
36
37
|
getMachineDir,
|
|
37
38
|
getMachinesDir,
|
|
@@ -43,13 +44,15 @@ import {
|
|
|
43
44
|
getDataDir,
|
|
44
45
|
} from "./state.ts";
|
|
45
46
|
import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
|
|
47
|
+
import { autoPruneIfOverCap } from "./cache.ts";
|
|
46
48
|
import { buildQemuArgs, spawnQemu, stopQemu, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
|
|
47
49
|
import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
|
|
48
50
|
import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
|
|
49
51
|
import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
|
|
50
52
|
import { provision } from "./provision.ts";
|
|
51
53
|
import { renewLicense, getLicenseInfo } from "./license.ts";
|
|
52
|
-
import { resolveAuth } from "./auth.ts";
|
|
54
|
+
import { resolveAuth, resolveCreds } from "./auth.ts";
|
|
55
|
+
import { scpPush, scpPull } from "./scp.ts";
|
|
53
56
|
import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials } from "./credentials.ts";
|
|
54
57
|
import { restExecute } from "./exec.ts";
|
|
55
58
|
import { qgaExec } from "./qga.ts";
|
|
@@ -109,6 +112,16 @@ function hasProvisioningMutations(opts: {
|
|
|
109
112
|
);
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/** Normalize convenience aliases on StartOptions:
|
|
116
|
+
* - `noAuth: true` → `secureLogin: false` (only when secureLogin is not explicitly set).
|
|
117
|
+
* Returns a shallow copy so the caller's input object is not mutated. */
|
|
118
|
+
function normalizeStartOptions(opts: StartOptions): StartOptions {
|
|
119
|
+
if (opts.noAuth === true && opts.secureLogin === undefined) {
|
|
120
|
+
return { ...opts, secureLogin: false };
|
|
121
|
+
}
|
|
122
|
+
return opts;
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
function listProvisioningMutations(opts: {
|
|
113
126
|
installAllPackages?: boolean;
|
|
114
127
|
packages?: string[];
|
|
@@ -166,7 +179,7 @@ function registerSocketMembers(state: MachineState): void {
|
|
|
166
179
|
for (const name of getSocketNamedNetworks(state)) {
|
|
167
180
|
try {
|
|
168
181
|
if (!getNamedSocket(name)) {
|
|
169
|
-
createNamedSocket(name);
|
|
182
|
+
createNamedSocket(name, { autoCreated: true });
|
|
170
183
|
}
|
|
171
184
|
addSocketMember(name, state.name);
|
|
172
185
|
} catch (e) {
|
|
@@ -218,6 +231,9 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
218
231
|
ports,
|
|
219
232
|
restUrl,
|
|
220
233
|
sshPort: ports.ssh,
|
|
234
|
+
portBase: state.portBase,
|
|
235
|
+
captureInterface: process.platform === "darwin" ? "lo0" : "any",
|
|
236
|
+
tzspGatewayIp: "10.0.2.2",
|
|
221
237
|
|
|
222
238
|
async waitForBoot(timeoutMs?: number): Promise<boolean> {
|
|
223
239
|
// Use resolved credentials so waitForBoot can validate the response body
|
|
@@ -226,6 +242,19 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
226
242
|
return waitForBoot(ports.http, timeoutMs, auth.header);
|
|
227
243
|
},
|
|
228
244
|
|
|
245
|
+
async waitFor(condition: () => Promise<boolean>, timeoutMs = 30_000): Promise<boolean> {
|
|
246
|
+
const deadline = Date.now() + timeoutMs;
|
|
247
|
+
while (Date.now() < deadline) {
|
|
248
|
+
try {
|
|
249
|
+
if (await condition()) return true;
|
|
250
|
+
} catch { /* swallow — condition may throw before the state is ready */ }
|
|
251
|
+
const remaining = deadline - Date.now();
|
|
252
|
+
if (remaining <= 0) break;
|
|
253
|
+
await Bun.sleep(Math.min(2000, remaining));
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
},
|
|
257
|
+
|
|
229
258
|
async stop(): Promise<void> {
|
|
230
259
|
if (state.pid) {
|
|
231
260
|
await stopQemu(state.pid);
|
|
@@ -514,6 +543,28 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
514
543
|
return installed;
|
|
515
544
|
},
|
|
516
545
|
|
|
546
|
+
async upload(localPath: string, remotePath?: string): Promise<void> {
|
|
547
|
+
if (!isMachineRunning(state)) {
|
|
548
|
+
throw new QuickCHRError(
|
|
549
|
+
"MACHINE_STOPPED",
|
|
550
|
+
`Machine "${state.name}" must be running to upload files.`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
const creds = resolveCreds(state);
|
|
554
|
+
await scpPush(localPath, remotePath, { sshPort: ports.ssh, ...creds });
|
|
555
|
+
},
|
|
556
|
+
|
|
557
|
+
async download(remotePath: string, localPath: string): Promise<void> {
|
|
558
|
+
if (!isMachineRunning(state)) {
|
|
559
|
+
throw new QuickCHRError(
|
|
560
|
+
"MACHINE_STOPPED",
|
|
561
|
+
`Machine "${state.name}" must be running to download files.`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
const creds = resolveCreds(state);
|
|
565
|
+
await scpPull(remotePath, localPath, { sshPort: ports.ssh, ...creds });
|
|
566
|
+
},
|
|
567
|
+
|
|
517
568
|
async destroy(): Promise<void> {
|
|
518
569
|
await this.stop();
|
|
519
570
|
await this.remove();
|
|
@@ -690,6 +741,12 @@ function hostArchToChr(): Arch {
|
|
|
690
741
|
return "x86";
|
|
691
742
|
}
|
|
692
743
|
|
|
744
|
+
/** Resolve user-supplied arch (including the "auto" synonym) to a concrete Arch. */
|
|
745
|
+
function resolveArch(input: Arch | "auto" | undefined): Arch {
|
|
746
|
+
if (input === undefined || input === "auto") return hostArchToChr();
|
|
747
|
+
return input;
|
|
748
|
+
}
|
|
749
|
+
|
|
693
750
|
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
694
751
|
const deadline = Date.now() + timeoutMs;
|
|
695
752
|
while (Date.now() < deadline) {
|
|
@@ -937,6 +994,7 @@ export class QuickCHR {
|
|
|
937
994
|
* and applied automatically on the first subsequent start(). Disk options (`bootSize`,
|
|
938
995
|
* `extraDisks`) are materialized immediately and require `qemu-img` on the host. */
|
|
939
996
|
static async add(opts: StartOptions = {}): Promise<MachineState> {
|
|
997
|
+
opts = normalizeStartOptions(opts);
|
|
940
998
|
if (opts.name?.startsWith("-")) {
|
|
941
999
|
throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
|
|
942
1000
|
}
|
|
@@ -952,7 +1010,7 @@ export class QuickCHR {
|
|
|
952
1010
|
version = await resolveVersion(channel);
|
|
953
1011
|
}
|
|
954
1012
|
|
|
955
|
-
const arch: Arch = opts.arch
|
|
1013
|
+
const arch: Arch = resolveArch(opts.arch);
|
|
956
1014
|
requireQemu(arch);
|
|
957
1015
|
if (arch === "arm64") requireFirmware();
|
|
958
1016
|
const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
|
|
@@ -987,6 +1045,7 @@ export class QuickCHR {
|
|
|
987
1045
|
|
|
988
1046
|
const usedBases = getUsedPortBases();
|
|
989
1047
|
const portBase = opts.portBase ?? await findAvailablePortBlock(usedBases, opts.excludePorts, opts.extraPorts);
|
|
1048
|
+
validateExplicitExtraPorts(opts.extraPorts, portBase, opts.excludePorts, loadAllMachines(), name);
|
|
990
1049
|
const ports = buildPortMappings(portBase, opts.excludePorts, opts.extraPorts);
|
|
991
1050
|
|
|
992
1051
|
const machineDir = getMachineDir(name);
|
|
@@ -1017,6 +1076,7 @@ export class QuickCHR {
|
|
|
1017
1076
|
deviceMode: opts.deviceMode,
|
|
1018
1077
|
user: opts.user,
|
|
1019
1078
|
disableAdmin: opts.disableAdmin,
|
|
1079
|
+
secureLogin: opts.secureLogin,
|
|
1020
1080
|
portBase,
|
|
1021
1081
|
excludePorts: opts.excludePorts ?? [],
|
|
1022
1082
|
extraPorts: opts.extraPorts ?? [],
|
|
@@ -1044,6 +1104,7 @@ export class QuickCHR {
|
|
|
1044
1104
|
* apply when creating a new machine and require `qemu-img` on the host.
|
|
1045
1105
|
*/
|
|
1046
1106
|
static async start(opts: StartOptions = {}): Promise<ChrInstance> {
|
|
1107
|
+
opts = normalizeStartOptions(opts);
|
|
1047
1108
|
// Validate name early (before any I/O) so callers get a fast, clear error
|
|
1048
1109
|
if (opts.name?.startsWith("-")) {
|
|
1049
1110
|
throw new QuickCHRError("INVALID_NAME", `Invalid machine name "${opts.name}" — names cannot start with "-"`);
|
|
@@ -1062,6 +1123,9 @@ export class QuickCHR {
|
|
|
1062
1123
|
let version: string;
|
|
1063
1124
|
if (opts.version) {
|
|
1064
1125
|
if ((CHANNELS as string[]).includes(opts.version)) {
|
|
1126
|
+
logger.warn(
|
|
1127
|
+
`'${opts.version}' is a channel name, not a version — passed as 'version' but resolved as a channel. Use 'channel: "${opts.version}"' instead to make this explicit.`,
|
|
1128
|
+
);
|
|
1065
1129
|
version = await resolveVersion(opts.version as Channel);
|
|
1066
1130
|
} else if (!isValidVersion(opts.version)) {
|
|
1067
1131
|
throw new QuickCHRError("INVALID_VERSION", `Invalid version: ${opts.version}`);
|
|
@@ -1094,7 +1158,7 @@ export class QuickCHR {
|
|
|
1094
1158
|
}
|
|
1095
1159
|
|
|
1096
1160
|
// Resolve architecture
|
|
1097
|
-
const arch: Arch = opts.arch
|
|
1161
|
+
const arch: Arch = resolveArch(opts.arch);
|
|
1098
1162
|
const diskOpts = normalizeDiskOptions(opts.bootSize, opts.extraDisks, opts.bootDiskFormat);
|
|
1099
1163
|
|
|
1100
1164
|
// Check prerequisites (skip for dry-run — no QEMU needed)
|
|
@@ -1152,6 +1216,14 @@ export class QuickCHR {
|
|
|
1152
1216
|
opts.extraPorts,
|
|
1153
1217
|
);
|
|
1154
1218
|
|
|
1219
|
+
validateExplicitExtraPorts(
|
|
1220
|
+
opts.extraPorts,
|
|
1221
|
+
portBase,
|
|
1222
|
+
opts.excludePorts,
|
|
1223
|
+
loadAllMachines(),
|
|
1224
|
+
name,
|
|
1225
|
+
);
|
|
1226
|
+
|
|
1155
1227
|
const ports = buildPortMappings(
|
|
1156
1228
|
portBase,
|
|
1157
1229
|
opts.excludePorts,
|
|
@@ -1173,6 +1245,7 @@ export class QuickCHR {
|
|
|
1173
1245
|
deviceMode: requestedDeviceMode,
|
|
1174
1246
|
user: opts.user,
|
|
1175
1247
|
disableAdmin: opts.disableAdmin,
|
|
1248
|
+
secureLogin: opts.secureLogin,
|
|
1176
1249
|
portBase,
|
|
1177
1250
|
excludePorts: opts.excludePorts ?? [],
|
|
1178
1251
|
extraPorts: opts.extraPorts ?? [],
|
|
@@ -1247,6 +1320,7 @@ export class QuickCHR {
|
|
|
1247
1320
|
deviceMode: requestedDeviceMode,
|
|
1248
1321
|
user: opts.user,
|
|
1249
1322
|
disableAdmin: opts.disableAdmin,
|
|
1323
|
+
secureLogin: opts.secureLogin,
|
|
1250
1324
|
portBase,
|
|
1251
1325
|
excludePorts: opts.excludePorts ?? [],
|
|
1252
1326
|
extraPorts: opts.extraPorts ?? [],
|
|
@@ -1584,6 +1658,10 @@ export class QuickCHR {
|
|
|
1584
1658
|
await QuickCHR._provisionInstance(instance, state, provisioningOpts, launchConfig, logger);
|
|
1585
1659
|
}
|
|
1586
1660
|
|
|
1661
|
+
try {
|
|
1662
|
+
autoPruneIfOverCap({ logger: logger ? (msg) => logger.status(msg) : undefined, protectVersions: [state.version] });
|
|
1663
|
+
} catch { /* never propagate */ }
|
|
1664
|
+
|
|
1587
1665
|
return instance;
|
|
1588
1666
|
} finally {
|
|
1589
1667
|
try { unlinkSync(lockPath); } catch { /* ignore */ }
|
package/src/lib/scp.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCP file transfer to/from a running CHR.
|
|
3
|
+
*
|
|
4
|
+
* Uses the same SSH_ASKPASS trick as {@link ./packages.ts uploadPackages} so
|
|
5
|
+
* no `sshpass` dependency is required. Both directions share the askpass
|
|
6
|
+
* lifecycle (written to tmpdir, cleaned up on exit).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { chmodSync, unlinkSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { QuickCHRError } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
export interface ScpOptions {
|
|
16
|
+
sshPort: number;
|
|
17
|
+
user: string;
|
|
18
|
+
password: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Copy a local file to the CHR. `remotePath` defaults to `/<basename>` (RouterOS flash root). */
|
|
22
|
+
export async function scpPush(
|
|
23
|
+
localPath: string,
|
|
24
|
+
remotePath: string | undefined,
|
|
25
|
+
opts: ScpOptions,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const remote = remotePath ?? `/${basename(localPath)}`;
|
|
28
|
+
await runScp([localPath, `${opts.user}@127.0.0.1:${remote}`], opts, `upload ${basename(localPath)}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Copy a file from the CHR to the local filesystem. */
|
|
32
|
+
export async function scpPull(
|
|
33
|
+
remotePath: string,
|
|
34
|
+
localPath: string,
|
|
35
|
+
opts: ScpOptions,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
await runScp([`${opts.user}@127.0.0.1:${remotePath}`, localPath], opts, `download ${remotePath}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Shared plumbing: write askpass, invoke `scp`, clean up, translate exit code to QuickCHRError. */
|
|
41
|
+
async function runScp(
|
|
42
|
+
scpArgs: string[],
|
|
43
|
+
opts: ScpOptions,
|
|
44
|
+
label: string,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const { askpassPath, env } = await writeAskpass(opts.password);
|
|
47
|
+
try {
|
|
48
|
+
const args = [
|
|
49
|
+
"scp",
|
|
50
|
+
"-P", String(opts.sshPort),
|
|
51
|
+
"-o", "StrictHostKeyChecking=accept-new",
|
|
52
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
53
|
+
...scpArgs,
|
|
54
|
+
];
|
|
55
|
+
const result = Bun.spawnSync(args, {
|
|
56
|
+
stdout: "pipe",
|
|
57
|
+
stderr: "pipe",
|
|
58
|
+
env,
|
|
59
|
+
});
|
|
60
|
+
if (result.exitCode !== 0) {
|
|
61
|
+
const stderr = new TextDecoder().decode(result.stderr);
|
|
62
|
+
throw new QuickCHRError(
|
|
63
|
+
"PROCESS_FAILED",
|
|
64
|
+
`SCP ${label} failed: ${stderr.trim() || `exit ${result.exitCode}`}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
} finally {
|
|
68
|
+
try { unlinkSync(askpassPath); } catch { /* ignore */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Write an SSH_ASKPASS helper for the given password.
|
|
74
|
+
*
|
|
75
|
+
* SSH_ASKPASS: a small program OpenSSH invokes to fetch a password when no
|
|
76
|
+
* TTY is available. `SSH_ASKPASS_REQUIRE=prefer` (OpenSSH 8.4+) makes it
|
|
77
|
+
* work without a display. On Windows we emit a `.cmd`; on Unix a `.sh`
|
|
78
|
+
* with the execute bit set.
|
|
79
|
+
*/
|
|
80
|
+
async function writeAskpass(password: string): Promise<{ askpassPath: string; env: Record<string, string> }> {
|
|
81
|
+
const isWindows = process.platform === "win32";
|
|
82
|
+
const ext = isWindows ? ".cmd" : ".sh";
|
|
83
|
+
const askpassPath = join(tmpdir(), `quickchr-askpass-${process.pid}-${Date.now()}${ext}`);
|
|
84
|
+
if (isWindows) {
|
|
85
|
+
await Bun.write(askpassPath, `@echo off\r\necho ${password.replace(/[&|<>^%]/g, "^$&")}\r\n`);
|
|
86
|
+
} else {
|
|
87
|
+
await Bun.write(askpassPath, `#!/bin/sh\nprintf '%s' '${password.replace(/'/g, "'\\''")}'`);
|
|
88
|
+
chmodSync(askpassPath, 0o755);
|
|
89
|
+
}
|
|
90
|
+
const env: Record<string, string> = {
|
|
91
|
+
...(process.env as Record<string, string>),
|
|
92
|
+
DISPLAY: "",
|
|
93
|
+
SSH_ASKPASS: askpassPath,
|
|
94
|
+
SSH_ASKPASS_REQUIRE: "prefer",
|
|
95
|
+
};
|
|
96
|
+
return { askpassPath, env };
|
|
97
|
+
}
|