@tikoci/quickchr 0.2.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/DESIGN.md +193 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/package.json +61 -0
- package/src/cli/format.ts +154 -0
- package/src/cli/index.ts +2625 -0
- package/src/cli/wizard.ts +727 -0
- package/src/index.ts +124 -0
- package/src/lib/auth.ts +68 -0
- package/src/lib/channels.ts +222 -0
- package/src/lib/completions.ts +513 -0
- package/src/lib/console.ts +473 -0
- package/src/lib/credentials.ts +99 -0
- package/src/lib/device-mode.ts +283 -0
- package/src/lib/disk.ts +400 -0
- package/src/lib/exec.ts +101 -0
- package/src/lib/images.ts +167 -0
- package/src/lib/license.ts +292 -0
- package/src/lib/log.ts +41 -0
- package/src/lib/network.ts +635 -0
- package/src/lib/packages.ts +225 -0
- package/src/lib/password.ts +21 -0
- package/src/lib/platform.ts +458 -0
- package/src/lib/provision.ts +422 -0
- package/src/lib/qemu.ts +526 -0
- package/src/lib/qga.ts +667 -0
- package/src/lib/quickchr.ts +1871 -0
- package/src/lib/rest.ts +244 -0
- package/src/lib/secrets.ts +133 -0
- package/src/lib/socket-registry.ts +140 -0
- package/src/lib/state.ts +173 -0
- package/src/lib/storage.ts +163 -0
- package/src/lib/types.ts +690 -0
- package/src/lib/versions.ts +181 -0
- package/src/lib/zip.ts +44 -0
package/src/lib/exec.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute RouterOS CLI commands via the REST /execute endpoint.
|
|
3
|
+
*
|
|
4
|
+
* POST /rest/execute { "script": "<cli-command>", "as-string": true }
|
|
5
|
+
*
|
|
6
|
+
* RouterOS 7.1+ exposes this endpoint as a universal command runner.
|
|
7
|
+
* `as-string` controls fg/bg mode: the **presence** of the key (with any
|
|
8
|
+
* value — true, false, "", "false") makes it synchronous (fg) and returns
|
|
9
|
+
* {"ret":"<output>"}. When the key is absent entirely, RouterOS runs the
|
|
10
|
+
* script in the background and returns a job ID (e.g. {"ret":"*B70A"}).
|
|
11
|
+
*
|
|
12
|
+
* The 60-second server-side timeout applies; callers can set a shorter
|
|
13
|
+
* client-side timeout via AbortSignal.
|
|
14
|
+
*
|
|
15
|
+
* Uses node:http with agent:false (no connection pooling). Bun's fetch
|
|
16
|
+
* pools TCP connections by host:port; RouterOS may return stale session
|
|
17
|
+
* data on a reused connection immediately after provisioning (SSH key
|
|
18
|
+
* install leaves state on the connection the verification loop used).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExecOptions, ExecResult, ExecTransport } from "./types.ts";
|
|
22
|
+
import { QuickCHRError } from "./types.ts";
|
|
23
|
+
import type { ResolvedAuth } from "./auth.ts";
|
|
24
|
+
import { restPost } from "./rest.ts";
|
|
25
|
+
|
|
26
|
+
/** Default client-side timeout for exec requests (ms). */
|
|
27
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Execute a RouterOS CLI command via POST /rest/execute.
|
|
31
|
+
*
|
|
32
|
+
* The command is passed through as-is — no wrapping or transformation.
|
|
33
|
+
* If you need structured JSON output, wrap the command yourself:
|
|
34
|
+
* `:put [:serialize to=json [/system/resource/print]]`
|
|
35
|
+
*
|
|
36
|
+
* @param restUrl Base URL of the CHR REST API, e.g. "http://127.0.0.1:9100"
|
|
37
|
+
* @param auth Resolved credentials (from resolveAuth).
|
|
38
|
+
* @param command RouterOS CLI command string, e.g. "/system/resource/print"
|
|
39
|
+
* @param opts Optional exec settings (timeout).
|
|
40
|
+
* @returns ExecResult with output text and transport used.
|
|
41
|
+
*/
|
|
42
|
+
export async function restExecute(
|
|
43
|
+
restUrl: string,
|
|
44
|
+
auth: ResolvedAuth,
|
|
45
|
+
command: string,
|
|
46
|
+
opts?: Pick<ExecOptions, "timeout">,
|
|
47
|
+
): Promise<ExecResult> {
|
|
48
|
+
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
49
|
+
const url = `${restUrl}/rest/execute`;
|
|
50
|
+
|
|
51
|
+
let status: number;
|
|
52
|
+
let data: string;
|
|
53
|
+
try {
|
|
54
|
+
const resp = await restPost(url, auth.header, { script: command, "as-string": true }, timeout);
|
|
55
|
+
status = resp.status;
|
|
56
|
+
data = resp.body;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
throw new QuickCHRError(
|
|
59
|
+
"EXEC_FAILED",
|
|
60
|
+
`exec timed out or failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (status < 200 || status >= 300) {
|
|
65
|
+
throw new QuickCHRError(
|
|
66
|
+
"EXEC_FAILED",
|
|
67
|
+
`exec failed (HTTP ${status}): ${data || "unknown error"}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let output: string;
|
|
72
|
+
try {
|
|
73
|
+
const json = JSON.parse(data);
|
|
74
|
+
output = extractRetValue(json);
|
|
75
|
+
} catch {
|
|
76
|
+
// Non-JSON response — return raw text
|
|
77
|
+
output = data;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { output, via: "rest" as ExecTransport };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract the output string from an /execute response.
|
|
85
|
+
*
|
|
86
|
+
* With `as-string`, RouterOS always returns { "ret": "<output>" } for
|
|
87
|
+
* commands that produce output, or [] for void commands (e.g. /log/info).
|
|
88
|
+
*/
|
|
89
|
+
function extractRetValue(json: unknown): string {
|
|
90
|
+
if (Array.isArray(json)) {
|
|
91
|
+
if (json.length === 0) return "";
|
|
92
|
+
return JSON.stringify(json);
|
|
93
|
+
}
|
|
94
|
+
if (typeof json === "object" && json !== null) {
|
|
95
|
+
const obj = json as Record<string, unknown>;
|
|
96
|
+
if ("ret" in obj) return String(obj.ret);
|
|
97
|
+
if ("message" in obj) return String(obj.message);
|
|
98
|
+
return JSON.stringify(obj);
|
|
99
|
+
}
|
|
100
|
+
return String(json);
|
|
101
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image download, ZIP extraction, and cache management.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, copyFileSync, readdirSync, renameSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { join, basename } from "node:path";
|
|
7
|
+
import type { Arch } from "./types.ts";
|
|
8
|
+
import { QuickCHRError } from "./types.ts";
|
|
9
|
+
import { chrDownloadUrl, chrImageBasename } from "./versions.ts";
|
|
10
|
+
import { getCacheDir, ensureDir } from "./state.ts";
|
|
11
|
+
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
12
|
+
import { extractZip } from "./zip.ts";
|
|
13
|
+
import { assertSufficientQuickchrStorage } from "./storage.ts";
|
|
14
|
+
|
|
15
|
+
function finalizeExtractedImage(zipPath: string, imgPath: string): string {
|
|
16
|
+
if (existsSync(imgPath) && existsSync(zipPath)) {
|
|
17
|
+
unlinkSync(zipPath);
|
|
18
|
+
}
|
|
19
|
+
return imgPath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Download a CHR image ZIP if not already cached. Returns path to the ZIP. */
|
|
23
|
+
export async function downloadImage(
|
|
24
|
+
version: string,
|
|
25
|
+
arch: Arch,
|
|
26
|
+
cacheDir?: string,
|
|
27
|
+
logger?: ProgressLogger,
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
const cache = cacheDir ?? getCacheDir();
|
|
30
|
+
ensureDir(cache);
|
|
31
|
+
|
|
32
|
+
const url = chrDownloadUrl(version, arch);
|
|
33
|
+
const zipName = `${chrImageBasename(version, arch)}.img.zip`;
|
|
34
|
+
const zipPath = join(cache, zipName);
|
|
35
|
+
|
|
36
|
+
if (existsSync(zipPath)) {
|
|
37
|
+
return zipPath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const log = logger ?? createLogger();
|
|
41
|
+
log.status(`Downloading CHR ${version} (${arch})...`);
|
|
42
|
+
log.status(` ${url}`);
|
|
43
|
+
|
|
44
|
+
const MAX_RETRIES = 3;
|
|
45
|
+
let lastError: Error | undefined;
|
|
46
|
+
|
|
47
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
|
50
|
+
// Non-retriable: client errors except 408 (request timeout) and 429
|
|
51
|
+
// (rate limited), which can be transient on CDNs.
|
|
52
|
+
if (response.status >= 400 && response.status < 500
|
|
53
|
+
&& response.status !== 408 && response.status !== 429) {
|
|
54
|
+
throw new QuickCHRError(
|
|
55
|
+
"DOWNLOAD_FAILED",
|
|
56
|
+
`Download failed: HTTP ${response.status} for ${url}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
61
|
+
} else {
|
|
62
|
+
// Stream to disk via arrayBuffer (Bun.write with Response can hang on large files)
|
|
63
|
+
const buf = await response.arrayBuffer();
|
|
64
|
+
await Bun.write(zipPath, buf);
|
|
65
|
+
log.status(` Saved (${(buf.byteLength / 1024 / 1024).toFixed(1)} MB)`);
|
|
66
|
+
return zipPath;
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (e instanceof QuickCHRError) throw e;
|
|
70
|
+
lastError = e instanceof Error ? e : new Error(String(e));
|
|
71
|
+
}
|
|
72
|
+
if (attempt < MAX_RETRIES) {
|
|
73
|
+
log.status(` Download failed (attempt ${attempt}/${MAX_RETRIES}), retrying in ${attempt * 2}s...`);
|
|
74
|
+
await Bun.sleep(attempt * 2000);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new QuickCHRError(
|
|
79
|
+
"DOWNLOAD_FAILED",
|
|
80
|
+
`Download failed after ${MAX_RETRIES} attempts: ${lastError?.message ?? "unknown error"}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Extract the .img from the ZIP. Returns path to the raw .img file. */
|
|
85
|
+
export async function extractImage(
|
|
86
|
+
zipPath: string,
|
|
87
|
+
cacheDir?: string,
|
|
88
|
+
logger?: ProgressLogger,
|
|
89
|
+
): Promise<string> {
|
|
90
|
+
const cache = cacheDir ?? getCacheDir();
|
|
91
|
+
ensureDir(cache);
|
|
92
|
+
|
|
93
|
+
const imgName = basename(zipPath, ".zip");
|
|
94
|
+
const imgPath = join(cache, imgName);
|
|
95
|
+
|
|
96
|
+
if (existsSync(imgPath)) {
|
|
97
|
+
return finalizeExtractedImage(zipPath, imgPath);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const log = logger ?? createLogger();
|
|
101
|
+
log.status(`Extracting: ${basename(zipPath)}`);
|
|
102
|
+
|
|
103
|
+
extractZip(zipPath, cache);
|
|
104
|
+
|
|
105
|
+
// MikroTik x86 ZIPs contain chr-X.Y.Z.img (no arch suffix).
|
|
106
|
+
// Our ZIP is named chr-X.Y.Z.img.zip (without -x86 for x86). Check if we need to
|
|
107
|
+
// find the extracted file.
|
|
108
|
+
if (!existsSync(imgPath)) {
|
|
109
|
+
const files = readdirSync(cache).filter(
|
|
110
|
+
(f) => f.endsWith(".img") && f.startsWith("chr-"),
|
|
111
|
+
);
|
|
112
|
+
// Find the one that was just extracted (matching version)
|
|
113
|
+
const expected = files.find((f) => {
|
|
114
|
+
const base = basename(zipPath, ".img.zip");
|
|
115
|
+
// chr-7.22.1.img matches chr-7.22.1.img.zip
|
|
116
|
+
return f === base + ".img" || f.replace("-arm64", "") === base.replace("-arm64", "") + ".img";
|
|
117
|
+
});
|
|
118
|
+
if (expected) {
|
|
119
|
+
const extractedPath = join(cache, expected);
|
|
120
|
+
if (extractedPath !== imgPath) {
|
|
121
|
+
renameSync(extractedPath, imgPath);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
throw new QuickCHRError(
|
|
125
|
+
"PROCESS_FAILED",
|
|
126
|
+
`Expected ${imgPath} after unzip, but not found. Files: ${files.join(", ")}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return finalizeExtractedImage(zipPath, imgPath);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Download and extract a CHR image. Returns path to the raw .img in cache. */
|
|
135
|
+
export async function ensureCachedImage(
|
|
136
|
+
version: string,
|
|
137
|
+
arch: Arch,
|
|
138
|
+
cacheDir?: string,
|
|
139
|
+
logger?: ProgressLogger,
|
|
140
|
+
): Promise<string> {
|
|
141
|
+
const cache = cacheDir ?? getCacheDir();
|
|
142
|
+
const imgPath = join(cache, `${chrImageBasename(version, arch)}.img`);
|
|
143
|
+
if (existsSync(imgPath)) {
|
|
144
|
+
const log = logger ?? createLogger();
|
|
145
|
+
log.status(` Using cached image: ${chrImageBasename(version, arch)}`);
|
|
146
|
+
return imgPath;
|
|
147
|
+
}
|
|
148
|
+
assertSufficientQuickchrStorage(`cache CHR ${version} (${arch})`);
|
|
149
|
+
const zipPath = await downloadImage(version, arch, cacheDir, logger);
|
|
150
|
+
const extractedImgPath = await extractImage(zipPath, cacheDir, logger);
|
|
151
|
+
return finalizeExtractedImage(zipPath, extractedImgPath);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Copy a cached image to a machine's working directory as disk.img. */
|
|
155
|
+
export function copyImageToMachine(cachedImgPath: string, machineDir: string): string {
|
|
156
|
+
ensureDir(machineDir);
|
|
157
|
+
const dest = join(machineDir, "disk.img");
|
|
158
|
+
copyFileSync(cachedImgPath, dest);
|
|
159
|
+
return dest;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** List cached images. */
|
|
163
|
+
export function listCachedImages(cacheDir?: string): string[] {
|
|
164
|
+
const cache = cacheDir ?? getCacheDir();
|
|
165
|
+
if (!existsSync(cache)) return [];
|
|
166
|
+
return readdirSync(cache).filter((f) => f.endsWith(".img"));
|
|
167
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHR license management — renew/get via /system/license REST endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Free CHR runs at 1 Mbps. A trial license from MikroTik unlocks full speed:
|
|
5
|
+
* p1 → 1 Gbps
|
|
6
|
+
* p10 → 10 Gbps
|
|
7
|
+
* unlimited → no cap
|
|
8
|
+
*
|
|
9
|
+
* License is applied by POST /rest/system/license/renew with MikroTik.com
|
|
10
|
+
* account credentials. No reboot required — takes effect immediately.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LicenseLevel, LicenseOptions } from "./types.ts";
|
|
14
|
+
import { QuickCHRError } from "./types.ts";
|
|
15
|
+
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
16
|
+
import { restGet, restPost } from "./rest.ts";
|
|
17
|
+
|
|
18
|
+
/** Default timeout for waiting for a license change to propagate (ms). */
|
|
19
|
+
const LICENSE_VERIFY_TIMEOUT = 90_000;
|
|
20
|
+
|
|
21
|
+
/** Max attempts for the renew POST itself (retried on post-boot REST quirk). */
|
|
22
|
+
const MAX_RENEW_ATTEMPTS = 3;
|
|
23
|
+
|
|
24
|
+
/** Classify the response body from POST /rest/system/license/renew.
|
|
25
|
+
*
|
|
26
|
+
* Observed RouterOS responses (confirmed via curl experiments):
|
|
27
|
+
*
|
|
28
|
+
* SUCCESS:
|
|
29
|
+
* [{".section":"0","status":"connecting"},{".section":"1","status":"done"}]
|
|
30
|
+
* → Last entry status="done" — license server accepted the request.
|
|
31
|
+
*
|
|
32
|
+
* ERROR (various):
|
|
33
|
+
* [{".section":"0","status":"connecting"},{".section":"1","status":"ERROR: Licensing Error: too many trial licences"}]
|
|
34
|
+
* [{".section":"0","status":"connecting"},{".section":"1","status":"ERROR: Unauthorized"}]
|
|
35
|
+
* → Last entry status starts with "ERROR:" — server rejected. Do NOT retry, do NOT poll.
|
|
36
|
+
*
|
|
37
|
+
* NOT-READY (post-boot REST quirk):
|
|
38
|
+
* {"board-name":"CHR","architecture-name":"x86_64",...}
|
|
39
|
+
* → System resource data leaked through. The endpoint isn't initialized. Retry the POST.
|
|
40
|
+
*
|
|
41
|
+
* HTTP 400: {"detail":"missing =account=","error":400,"message":"Bad Request"}
|
|
42
|
+
* → Caught before this function (HTTP status check).
|
|
43
|
+
*/
|
|
44
|
+
function classifyRenewResponse(text: string): "done" | "error" | "pending" | "not-ready" {
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(text);
|
|
47
|
+
|
|
48
|
+
if (Array.isArray(data)) {
|
|
49
|
+
const last = data[data.length - 1];
|
|
50
|
+
if (last && typeof last === "object" && typeof last.status === "string") {
|
|
51
|
+
if (last.status === "done") return "done";
|
|
52
|
+
if (last.status.startsWith("ERROR:")) return "error";
|
|
53
|
+
}
|
|
54
|
+
return "pending";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (data && typeof data === "object") {
|
|
58
|
+
if ("board-name" in data || "architecture-name" in data) return "not-ready";
|
|
59
|
+
}
|
|
60
|
+
} catch { /* unparseable — treat as pending */ }
|
|
61
|
+
|
|
62
|
+
return "pending";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Apply or renew a CHR trial license via /system/license/renew.
|
|
66
|
+
* @param httpPort REST API port (e.g. 9180)
|
|
67
|
+
* @param opts MikroTik account credentials + desired level
|
|
68
|
+
* @param chrUser RouterOS admin username (default "admin")
|
|
69
|
+
* @param chrPass RouterOS admin password (default "" for fresh CHR)
|
|
70
|
+
* @param logger Optional progress logger
|
|
71
|
+
* @param authHeader Pre-built Authorization header (overrides chrUser/chrPass when provided)
|
|
72
|
+
*/
|
|
73
|
+
export async function renewLicense(
|
|
74
|
+
httpPort: number,
|
|
75
|
+
opts: LicenseOptions,
|
|
76
|
+
chrUser = "admin",
|
|
77
|
+
chrPass = "",
|
|
78
|
+
logger?: ProgressLogger,
|
|
79
|
+
authHeader?: string,
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const log = logger ?? createLogger();
|
|
82
|
+
const auth = authHeader ?? `Basic ${btoa(`${chrUser}:${chrPass}`)}`;
|
|
83
|
+
const body: Record<string, string> = {};
|
|
84
|
+
if (opts.account) body.account = opts.account;
|
|
85
|
+
if (opts.password) body.password = opts.password;
|
|
86
|
+
if (opts.level) body.level = opts.level;
|
|
87
|
+
// duration=10s: ask RouterOS to wait up to 10s for the MikroTik license server
|
|
88
|
+
// before responding. Without this, RouterOS does one short internal poll (~1-2s)
|
|
89
|
+
// and returns before the activation has propagated, forcing a long polling loop.
|
|
90
|
+
body.duration = "10s";
|
|
91
|
+
|
|
92
|
+
// Wait for the license subsystem to be ready — right after boot, RouterOS may
|
|
93
|
+
// return system resource data for all REST endpoints (see waitForBoot stage 4).
|
|
94
|
+
await waitForLicenseApi(httpPort, chrUser, chrPass, 30_000, auth);
|
|
95
|
+
|
|
96
|
+
for (let attempt = 1; attempt <= MAX_RENEW_ATTEMPTS; attempt++) {
|
|
97
|
+
let status: number;
|
|
98
|
+
let text: string;
|
|
99
|
+
try {
|
|
100
|
+
const resp = await restPost(
|
|
101
|
+
`http://127.0.0.1:${httpPort}/rest/system/license/renew`,
|
|
102
|
+
auth,
|
|
103
|
+
body,
|
|
104
|
+
30_000, // 10s for RouterOS wait + 20s net overhead
|
|
105
|
+
);
|
|
106
|
+
status = resp.status;
|
|
107
|
+
text = resp.body;
|
|
108
|
+
} catch (e) {
|
|
109
|
+
throw new QuickCHRError(
|
|
110
|
+
"PROCESS_FAILED",
|
|
111
|
+
`License renewal request failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (status < 200 || status >= 300) {
|
|
116
|
+
throw new QuickCHRError(
|
|
117
|
+
"PROCESS_FAILED",
|
|
118
|
+
`License renewal failed: HTTP ${status} — ${text}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
log.debug(`License renew response body: ${text}`);
|
|
123
|
+
const result = classifyRenewResponse(text);
|
|
124
|
+
|
|
125
|
+
if (result === "error") {
|
|
126
|
+
// Extract the actual error message from the RouterOS response
|
|
127
|
+
let errorDetail = text;
|
|
128
|
+
try {
|
|
129
|
+
const data = JSON.parse(text);
|
|
130
|
+
if (Array.isArray(data)) {
|
|
131
|
+
const last = data[data.length - 1];
|
|
132
|
+
if (last?.status) errorDetail = last.status;
|
|
133
|
+
}
|
|
134
|
+
} catch { /* use raw text */ }
|
|
135
|
+
throw new QuickCHRError(
|
|
136
|
+
"PROCESS_FAILED",
|
|
137
|
+
`License renewal rejected by MikroTik: ${errorDetail}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (result === "not-ready") {
|
|
142
|
+
if (attempt < MAX_RENEW_ATTEMPTS) {
|
|
143
|
+
log.warn(`License renew attempt ${attempt}: got system resource data instead of renew status — retrying`);
|
|
144
|
+
await Bun.sleep(5000);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
throw new QuickCHRError(
|
|
148
|
+
"PROCESS_FAILED",
|
|
149
|
+
"License renew endpoint returned system resource data after multiple attempts — REST API not fully initialized",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!opts.level) return; // fire-and-forget, no level verification needed
|
|
154
|
+
|
|
155
|
+
// result is "done" or "pending" — poll until the level matches.
|
|
156
|
+
// "done" means the license server responded within the duration window;
|
|
157
|
+
// a single verification poll should suffice but we use the same loop.
|
|
158
|
+
const deadline = Date.now() + LICENSE_VERIFY_TIMEOUT;
|
|
159
|
+
let pollCount = 0;
|
|
160
|
+
while (Date.now() < deadline) {
|
|
161
|
+
try {
|
|
162
|
+
const info = await getLicenseInfo(httpPort, chrUser, chrPass, auth);
|
|
163
|
+
pollCount++;
|
|
164
|
+
log.debug(`License poll #${pollCount}: level=${info.level}, want=${opts.level}`);
|
|
165
|
+
if (info.level === opts.level) return;
|
|
166
|
+
} catch (e) {
|
|
167
|
+
pollCount++;
|
|
168
|
+
log.warn(`License poll #${pollCount} error: ${e instanceof Error ? e.message : String(e)}`);
|
|
169
|
+
}
|
|
170
|
+
await Bun.sleep(2000);
|
|
171
|
+
}
|
|
172
|
+
throw new QuickCHRError(
|
|
173
|
+
"PROCESS_FAILED",
|
|
174
|
+
`License renewal accepted but level did not change to "${opts.level}" within ${LICENSE_VERIFY_TIMEOUT / 1000}s`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Wait until GET /rest/system/license returns actual license data.
|
|
180
|
+
* Right after boot, RouterOS may return system resource data for all endpoints.
|
|
181
|
+
* Real license data contains "system-id"; system resource data has "board-name". */
|
|
182
|
+
async function waitForLicenseApi(
|
|
183
|
+
httpPort: number,
|
|
184
|
+
chrUser: string,
|
|
185
|
+
chrPass: string,
|
|
186
|
+
timeoutMs = 30_000,
|
|
187
|
+
authHeader?: string,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
const auth = authHeader ?? `Basic ${btoa(`${chrUser}:${chrPass}`)}`;
|
|
190
|
+
const deadline = Date.now() + timeoutMs;
|
|
191
|
+
|
|
192
|
+
while (Date.now() < deadline) {
|
|
193
|
+
try {
|
|
194
|
+
const { status, body } = await restGet(
|
|
195
|
+
`http://127.0.0.1:${httpPort}/rest/system/license`,
|
|
196
|
+
auth,
|
|
197
|
+
5000,
|
|
198
|
+
);
|
|
199
|
+
if (status >= 200 && status < 300) {
|
|
200
|
+
const data = JSON.parse(body) as Record<string, unknown>;
|
|
201
|
+
if ("system-id" in data && !("board-name" in data)) return;
|
|
202
|
+
}
|
|
203
|
+
} catch { /* not ready */ }
|
|
204
|
+
await Bun.sleep(2000);
|
|
205
|
+
}
|
|
206
|
+
// Don't throw — the renew POST itself will detect and retry on bad responses.
|
|
207
|
+
console.warn("License API readiness check timed out — proceeding with renewal attempt");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Fetch the current license state from a running CHR.
|
|
211
|
+
* Uses GET /rest/system/license (standard REST read for a single-object menu).
|
|
212
|
+
* Normalises the response: RouterOS omits `level` for the implicit "free" tier,
|
|
213
|
+
* so we fill it in when absent.
|
|
214
|
+
* @param authHeader Pre-built Authorization header (overrides chrUser/chrPass when provided)
|
|
215
|
+
*/
|
|
216
|
+
export async function getLicenseInfo(
|
|
217
|
+
httpPort: number,
|
|
218
|
+
chrUser = "admin",
|
|
219
|
+
chrPass = "",
|
|
220
|
+
authHeader?: string,
|
|
221
|
+
retryMs = 15_000,
|
|
222
|
+
): Promise<LicenseInfo> {
|
|
223
|
+
const auth = authHeader ?? `Basic ${btoa(`${chrUser}:${chrPass}`)}`;
|
|
224
|
+
|
|
225
|
+
// RouterOS briefly serves /system/resource data from /system/license immediately
|
|
226
|
+
// after boot (REST routing startup race). Retry for up to retryMs to let it settle.
|
|
227
|
+
// do-while ensures at least one attempt even when retryMs=0 (useful in tests).
|
|
228
|
+
const deadline = Date.now() + retryMs;
|
|
229
|
+
let lastError: unknown;
|
|
230
|
+
|
|
231
|
+
do {
|
|
232
|
+
let status: number;
|
|
233
|
+
let body: string;
|
|
234
|
+
try {
|
|
235
|
+
const resp = await restGet(
|
|
236
|
+
`http://127.0.0.1:${httpPort}/rest/system/license`,
|
|
237
|
+
auth,
|
|
238
|
+
10_000,
|
|
239
|
+
);
|
|
240
|
+
status = resp.status;
|
|
241
|
+
body = resp.body;
|
|
242
|
+
} catch (e) {
|
|
243
|
+
lastError = e;
|
|
244
|
+
// Skip the sleep when past the deadline (e.g. retryMs=0 in tests).
|
|
245
|
+
await Bun.sleep(Math.max(0, Math.min(1000, deadline - Date.now())));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (status < 200 || status >= 300) {
|
|
250
|
+
throw new QuickCHRError(
|
|
251
|
+
"PROCESS_FAILED",
|
|
252
|
+
`Failed to get license info: HTTP ${status} — ${body}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let info: LicenseInfo;
|
|
257
|
+
try { info = JSON.parse(body) as LicenseInfo; } catch {
|
|
258
|
+
lastError = new Error(`Invalid JSON from /system/license: ${body}`);
|
|
259
|
+
await Bun.sleep(Math.max(0, Math.min(1000, deadline - Date.now())));
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
// Post-boot REST quirk: /system/license may return /system/resource data briefly.
|
|
263
|
+
// Retry until the endpoint stabilises rather than failing to the caller.
|
|
264
|
+
if ("board-name" in info || "architecture-name" in info) {
|
|
265
|
+
await Bun.sleep(Math.max(0, Math.min(1000, deadline - Date.now())));
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
// RouterOS REST omits default / empty fields. A CHR with no registered license
|
|
269
|
+
// is implicitly "free" — normalise to the explicit string so callers can always
|
|
270
|
+
// read info.level without special-casing undefined.
|
|
271
|
+
if (!info.level) info.level = "free";
|
|
272
|
+
return info;
|
|
273
|
+
} while (Date.now() < deadline);
|
|
274
|
+
|
|
275
|
+
const msg = lastError instanceof Error ? lastError.message : String(lastError ?? "timed out");
|
|
276
|
+
throw new QuickCHRError(
|
|
277
|
+
"PROCESS_FAILED",
|
|
278
|
+
`License info request failed after retries: ${msg}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Parsed response from /rest/system/license/get */
|
|
283
|
+
export interface LicenseInfo {
|
|
284
|
+
/** License level: "free", "p1", "p10", "unlimited", etc. */
|
|
285
|
+
level?: LicenseLevel | "free" | string;
|
|
286
|
+
/** Deadline in ISO-8601 format if on a trial. */
|
|
287
|
+
deadline?: string;
|
|
288
|
+
/** System ID used for activation. */
|
|
289
|
+
"system-id"?: string;
|
|
290
|
+
/** Any other fields RouterOS returns. */
|
|
291
|
+
[key: string]: unknown;
|
|
292
|
+
}
|
package/src/lib/log.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logging for quickchr library code.
|
|
3
|
+
* Routes messages through onProgress callback when provided,
|
|
4
|
+
* falls back to console.log/console.warn.
|
|
5
|
+
* Debug messages are suppressed unless QUICKCHR_DEBUG=1.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const isDebug = () => process.env.QUICKCHR_DEBUG === "1";
|
|
9
|
+
|
|
10
|
+
export interface ProgressLogger {
|
|
11
|
+
/** User-facing status message (e.g. "Uploading: foo.npk") */
|
|
12
|
+
status(message: string): void;
|
|
13
|
+
/** Debug message — only shown when QUICKCHR_DEBUG=1 */
|
|
14
|
+
debug(message: string): void;
|
|
15
|
+
/** Warning — always shown */
|
|
16
|
+
warn(message: string): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Create a logger from an optional onProgress callback. */
|
|
20
|
+
export function createLogger(onProgress?: (message: string) => void): ProgressLogger {
|
|
21
|
+
return {
|
|
22
|
+
status(message: string) {
|
|
23
|
+
if (onProgress) {
|
|
24
|
+
onProgress(message);
|
|
25
|
+
} else {
|
|
26
|
+
console.log(message);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
debug(message: string) {
|
|
30
|
+
if (!isDebug()) return;
|
|
31
|
+
if (onProgress) {
|
|
32
|
+
onProgress(`[debug] ${message}`);
|
|
33
|
+
} else {
|
|
34
|
+
console.error(`[debug] ${message}`);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
warn(message: string) {
|
|
38
|
+
console.warn(message);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|