@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
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serial console command execution for RouterOS CHR.
|
|
3
|
+
*
|
|
4
|
+
* Provides a higher-level interface over the raw serial socket stream
|
|
5
|
+
* from channels.ts. Handles the RouterOS console protocol:
|
|
6
|
+
* - Login sequence (username, password, license prompt, password change)
|
|
7
|
+
* - Prompt detection with offset tracking (prevents re-matching)
|
|
8
|
+
* - Command execution with output capture
|
|
9
|
+
* - ANSI escape sequence stripping
|
|
10
|
+
*
|
|
11
|
+
* Key lessons from chr-armed (tikoci/chr-armed):
|
|
12
|
+
* - Always use \r (not \r\n) — serial PTY treats \r\n as two inputs
|
|
13
|
+
* - matchOffset prevents waitFor("repeat new password>") from matching
|
|
14
|
+
* the earlier "new password>" occurrence
|
|
15
|
+
* - RouterOS prompt format: [admin@<identity>] > where identity varies
|
|
16
|
+
* by version (7.23+ defaults to "CHR" for CHR instances, older = "MikroTik")
|
|
17
|
+
* - On first boot: license [Y/n] prompt, then forced password change
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { connect, type Socket } from "node:net";
|
|
21
|
+
import { QuickCHRError } from "./types.ts";
|
|
22
|
+
import { channelEndpoint, channelFileExists, channelPath } from "./channels.ts";
|
|
23
|
+
|
|
24
|
+
/** Default timeout for console operations. */
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
/** Interval for polling the buffer in waitFor(). */
|
|
28
|
+
const POLL_INTERVAL_MS = 250;
|
|
29
|
+
|
|
30
|
+
/** Pre-prompt pattern: the "] > " suffix is version-proof since it
|
|
31
|
+
* covers [admin@MikroTik] > , [admin@CHR] > , and any custom identity. */
|
|
32
|
+
const PROMPT_PATTERN = "] > ";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Regex to strip ANSI escape sequences from console output.
|
|
36
|
+
* Covers CSI sequences, OSC sequences, and other common escapes.
|
|
37
|
+
* Uses RegExp constructor to avoid Biome noControlCharactersInRegex.
|
|
38
|
+
*/
|
|
39
|
+
const ANSI_RE = new RegExp(
|
|
40
|
+
"\x1b\\[[0-9;]*[a-zA-Z]" +
|
|
41
|
+
"|\x1b\\][^\x07]*\x07" +
|
|
42
|
+
"|\x1b[()][0-9A-B]" +
|
|
43
|
+
"|\x1b[>=<]" +
|
|
44
|
+
"|\x1b\\[[?]?[0-9;]*[hlm]",
|
|
45
|
+
"g",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/** Strip ANSI escape sequences from text. */
|
|
49
|
+
export function stripAnsi(text: string): string {
|
|
50
|
+
return text.replace(ANSI_RE, "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ConsoleSessionState {
|
|
54
|
+
socket: Socket;
|
|
55
|
+
buffer: string;
|
|
56
|
+
matchOffset: number;
|
|
57
|
+
streamDone: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Connect to the serial console socket and start buffer accumulation.
|
|
62
|
+
*/
|
|
63
|
+
function openSession(machineDir: string, portBase?: number): ConsoleSessionState {
|
|
64
|
+
const socketPath = channelPath(machineDir, "serial");
|
|
65
|
+
if (!channelFileExists(socketPath)) {
|
|
66
|
+
throw new QuickCHRError(
|
|
67
|
+
"MACHINE_STOPPED",
|
|
68
|
+
"Serial socket not found — is the machine running in background mode?",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const endpoint = channelEndpoint(machineDir, "serial", portBase);
|
|
73
|
+
const socket = typeof endpoint === "string"
|
|
74
|
+
? connect({ path: endpoint })
|
|
75
|
+
: connect(endpoint.port, endpoint.host);
|
|
76
|
+
const state: ConsoleSessionState = {
|
|
77
|
+
socket,
|
|
78
|
+
buffer: "",
|
|
79
|
+
matchOffset: 0,
|
|
80
|
+
streamDone: false,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
socket.on("data", (data: Buffer) => {
|
|
84
|
+
state.buffer += data.toString();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
socket.on("end", () => {
|
|
88
|
+
state.streamDone = true;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
socket.on("error", () => {
|
|
92
|
+
state.streamDone = true;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return state;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Wait for a pattern to appear in the buffer, searching only from
|
|
100
|
+
* matchOffset forward to avoid re-matching earlier occurrences.
|
|
101
|
+
*
|
|
102
|
+
* Returns index of match (relative to full buffer) or -1 on timeout.
|
|
103
|
+
*/
|
|
104
|
+
async function waitFor(
|
|
105
|
+
session: ConsoleSessionState,
|
|
106
|
+
pattern: string,
|
|
107
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
108
|
+
): Promise<number> {
|
|
109
|
+
const deadline = Date.now() + timeoutMs;
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
const idx = session.buffer.indexOf(pattern, session.matchOffset);
|
|
112
|
+
if (idx >= 0) {
|
|
113
|
+
session.matchOffset = idx + pattern.length;
|
|
114
|
+
return idx;
|
|
115
|
+
}
|
|
116
|
+
if (session.streamDone) return -1;
|
|
117
|
+
await Bun.sleep(POLL_INTERVAL_MS);
|
|
118
|
+
}
|
|
119
|
+
return -1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Wait for the LAST occurrence of `pattern` at or after `searchFrom` in the
|
|
124
|
+
* buffer, determined by buffer stability.
|
|
125
|
+
*
|
|
126
|
+
* RouterOS's VT100 terminal redraws the prompt+command line during execution,
|
|
127
|
+
* so the first "] > " after the command is just the redraw, not the output prompt.
|
|
128
|
+
* We wait until no new data arrives for `stableMs` milliseconds, then return
|
|
129
|
+
* the position of the LAST occurrence of `pattern`.
|
|
130
|
+
*/
|
|
131
|
+
async function waitForFinalPrompt(
|
|
132
|
+
session: ConsoleSessionState,
|
|
133
|
+
pattern: string,
|
|
134
|
+
searchFrom: number,
|
|
135
|
+
timeoutMs: number,
|
|
136
|
+
stableMs = 150,
|
|
137
|
+
): Promise<number> {
|
|
138
|
+
const FAST_POLL = 50;
|
|
139
|
+
const deadline = Date.now() + timeoutMs;
|
|
140
|
+
let lastGrowthAt = Date.now();
|
|
141
|
+
let lastLen = session.buffer.length;
|
|
142
|
+
let lastPromptAt = -1;
|
|
143
|
+
|
|
144
|
+
while (Date.now() < deadline) {
|
|
145
|
+
const currentLen = session.buffer.length;
|
|
146
|
+
if (currentLen > lastLen) {
|
|
147
|
+
lastLen = currentLen;
|
|
148
|
+
lastGrowthAt = Date.now();
|
|
149
|
+
const candidate = session.buffer.lastIndexOf(pattern);
|
|
150
|
+
if (candidate >= searchFrom) lastPromptAt = candidate;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (lastPromptAt >= 0 && Date.now() - lastGrowthAt >= stableMs) {
|
|
154
|
+
session.matchOffset = lastPromptAt + pattern.length;
|
|
155
|
+
return lastPromptAt;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (session.streamDone) {
|
|
159
|
+
if (lastPromptAt >= 0)
|
|
160
|
+
session.matchOffset = lastPromptAt + pattern.length;
|
|
161
|
+
return lastPromptAt;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await Bun.sleep(FAST_POLL);
|
|
165
|
+
}
|
|
166
|
+
return -1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Write data to the serial console.
|
|
171
|
+
* Always uses \r (not \r\n) — critical for serial PTY correctness.
|
|
172
|
+
*/
|
|
173
|
+
function write(session: ConsoleSessionState, data: string): void {
|
|
174
|
+
session.socket.write(data);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Perform the login sequence if needed.
|
|
179
|
+
*
|
|
180
|
+
* Handles:
|
|
181
|
+
* - Login: prompt → username + password
|
|
182
|
+
* - [Y/n]: license prompt → decline with "n"
|
|
183
|
+
* - new password> → skip with Ctrl-C
|
|
184
|
+
*
|
|
185
|
+
* Returns true if we reached a CLI prompt, false on timeout.
|
|
186
|
+
*/
|
|
187
|
+
async function ensureLoggedIn(
|
|
188
|
+
session: ConsoleSessionState,
|
|
189
|
+
user: string,
|
|
190
|
+
password: string,
|
|
191
|
+
timeoutMs: number,
|
|
192
|
+
): Promise<boolean> {
|
|
193
|
+
// Send \r to solicit a response (prompt or login).
|
|
194
|
+
// Re-poke every 2s — a fresh socket connection may not receive the login
|
|
195
|
+
// prompt until the terminal is nudged (e.g. after a cold boot or reconnect).
|
|
196
|
+
const POKE_INTERVAL_MS = 2_000;
|
|
197
|
+
write(session, "\r");
|
|
198
|
+
let lastPokeAt = Date.now();
|
|
199
|
+
let hasQuit = false;
|
|
200
|
+
|
|
201
|
+
const deadline = Date.now() + timeoutMs;
|
|
202
|
+
|
|
203
|
+
// Wait for something recognizable
|
|
204
|
+
while (Date.now() < deadline) {
|
|
205
|
+
const remaining = deadline - Date.now();
|
|
206
|
+
|
|
207
|
+
// If we see a CLI prompt, check whether we're already logged in as the right user.
|
|
208
|
+
// The prompt format is "[username@identity] > " — extract the username portion.
|
|
209
|
+
// If it matches the requested user, proceed directly without re-logging in.
|
|
210
|
+
// If it differs (previous session left open from a prior consoleExec call), log out
|
|
211
|
+
// once and re-authenticate as the correct user. Serial has no HUP concept, so the
|
|
212
|
+
// previous session persists across socket reconnects.
|
|
213
|
+
const promptIdx = session.buffer.indexOf(PROMPT_PATTERN, session.matchOffset);
|
|
214
|
+
if (promptIdx >= 0) {
|
|
215
|
+
const beforePrompt = session.buffer.slice(0, promptIdx);
|
|
216
|
+
const bracketIdx = beforePrompt.lastIndexOf("[");
|
|
217
|
+
const atIdx = bracketIdx >= 0 ? beforePrompt.indexOf("@", bracketIdx) : -1;
|
|
218
|
+
const currentUser =
|
|
219
|
+
bracketIdx >= 0 && atIdx > bracketIdx ? beforePrompt.slice(bracketIdx + 1, atIdx) : null;
|
|
220
|
+
|
|
221
|
+
session.matchOffset = promptIdx + PROMPT_PATTERN.length;
|
|
222
|
+
|
|
223
|
+
if (currentUser === user) {
|
|
224
|
+
// Already logged in as the correct user — proceed directly to command execution.
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!hasQuit) {
|
|
229
|
+
hasQuit = true;
|
|
230
|
+
write(session, "/quit\r");
|
|
231
|
+
await Bun.sleep(500);
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Check for Login: prompt
|
|
237
|
+
const loginIdx = session.buffer.indexOf("Login:", session.matchOffset);
|
|
238
|
+
if (loginIdx >= 0) {
|
|
239
|
+
session.matchOffset = loginIdx + "Login:".length;
|
|
240
|
+
|
|
241
|
+
// Send username
|
|
242
|
+
write(session, `${user}\r`);
|
|
243
|
+
|
|
244
|
+
// Wait for Password:
|
|
245
|
+
if (await waitFor(session, "Password:", Math.min(remaining, 10_000)) < 0) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Send password
|
|
250
|
+
write(session, `${password}\r`);
|
|
251
|
+
|
|
252
|
+
// Now handle possible post-login prompts:
|
|
253
|
+
// - License [Y/n] → "n\r"
|
|
254
|
+
// - "new password>" → Ctrl-C to skip
|
|
255
|
+
// - CLI prompt → done
|
|
256
|
+
return await handlePostLogin(session, deadline - Date.now());
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Re-poke the terminal periodically — no response yet
|
|
260
|
+
if (Date.now() - lastPokeAt >= POKE_INTERVAL_MS) {
|
|
261
|
+
write(session, "\r");
|
|
262
|
+
lastPokeAt = Date.now();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await Bun.sleep(POLL_INTERVAL_MS);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Handle post-login prompts (license, password change) until CLI prompt.
|
|
273
|
+
*/
|
|
274
|
+
async function handlePostLogin(
|
|
275
|
+
session: ConsoleSessionState,
|
|
276
|
+
timeoutMs: number,
|
|
277
|
+
): Promise<boolean> {
|
|
278
|
+
const deadline = Date.now() + timeoutMs;
|
|
279
|
+
|
|
280
|
+
while (Date.now() < deadline) {
|
|
281
|
+
const remaining = deadline - Date.now();
|
|
282
|
+
const buf = session.buffer;
|
|
283
|
+
const offset = session.matchOffset;
|
|
284
|
+
|
|
285
|
+
// Check for CLI prompt
|
|
286
|
+
const promptIdx = buf.indexOf(PROMPT_PATTERN, offset);
|
|
287
|
+
if (promptIdx >= 0) {
|
|
288
|
+
session.matchOffset = promptIdx + PROMPT_PATTERN.length;
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// License prompt: [Y/n]:
|
|
293
|
+
const licenseIdx = buf.indexOf("[Y/n]:", offset);
|
|
294
|
+
if (licenseIdx >= 0) {
|
|
295
|
+
session.matchOffset = licenseIdx + "[Y/n]:".length;
|
|
296
|
+
write(session, "n\r");
|
|
297
|
+
await Bun.sleep(POLL_INTERVAL_MS);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Password change prompt
|
|
302
|
+
const pwIdx = buf.indexOf("new password>", offset);
|
|
303
|
+
if (pwIdx >= 0) {
|
|
304
|
+
session.matchOffset = pwIdx + "new password>".length;
|
|
305
|
+
// Send Ctrl-C to skip password change
|
|
306
|
+
write(session, "\x03");
|
|
307
|
+
await Bun.sleep(POLL_INTERVAL_MS);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (session.streamDone) return false;
|
|
312
|
+
await Bun.sleep(Math.min(POLL_INTERVAL_MS, remaining));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Execute a RouterOS CLI command over the serial console.
|
|
320
|
+
*
|
|
321
|
+
* Connects to serial socket, ensures the session is logged in,
|
|
322
|
+
* sends the command, and captures output between the command echo
|
|
323
|
+
* and the next prompt.
|
|
324
|
+
*
|
|
325
|
+
* @param machineDir Machine directory containing serial.sock
|
|
326
|
+
* @param command RouterOS CLI command to execute
|
|
327
|
+
* @param user Username for login (default: "admin")
|
|
328
|
+
* @param password Password for login (default: "")
|
|
329
|
+
* @param timeoutMs Overall timeout
|
|
330
|
+
*/
|
|
331
|
+
export async function consoleExec(
|
|
332
|
+
machineDir: string,
|
|
333
|
+
command: string,
|
|
334
|
+
user: string = "admin",
|
|
335
|
+
password: string = "",
|
|
336
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
337
|
+
portBase?: number,
|
|
338
|
+
): Promise<{ output: string }> {
|
|
339
|
+
const session = openSession(machineDir, portBase);
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
// Wait for socket to connect
|
|
343
|
+
await new Promise<void>((resolve, reject) => {
|
|
344
|
+
session.socket.once("connect", resolve);
|
|
345
|
+
session.socket.once("error", (err: NodeJS.ErrnoException) => {
|
|
346
|
+
if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
|
|
347
|
+
reject(new QuickCHRError("MACHINE_STOPPED", "Serial channel not found — is the machine running in background mode?"));
|
|
348
|
+
} else {
|
|
349
|
+
reject(err);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Ensure logged in and at CLI prompt
|
|
355
|
+
const loggedIn = await ensureLoggedIn(session, user, password, Math.min(timeoutMs, 15_000));
|
|
356
|
+
if (!loggedIn) {
|
|
357
|
+
throw new QuickCHRError(
|
|
358
|
+
"BOOT_TIMEOUT",
|
|
359
|
+
"Console exec: could not reach CLI prompt (login timed out)",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Record buffer position before sending command
|
|
364
|
+
const preCommandOffset = session.buffer.length;
|
|
365
|
+
// Advance matchOffset past any data already buffered after the login prompt.
|
|
366
|
+
// waitFor must only match the prompt that appears AFTER our command — not a
|
|
367
|
+
// pre-buffered "] > " from ANSI codes or OSC title sequences RouterOS emits.
|
|
368
|
+
session.matchOffset = Math.max(session.matchOffset, preCommandOffset);
|
|
369
|
+
|
|
370
|
+
// Send command
|
|
371
|
+
write(session, `${command}\r`);
|
|
372
|
+
|
|
373
|
+
// Wait for the FINAL prompt after the command completes.
|
|
374
|
+
// RouterOS VT100 redraws the prompt+command line during execution, so the
|
|
375
|
+
// first "] > " is a redraw, not the output prompt. waitForFinalPrompt uses
|
|
376
|
+
// buffer stability (no new data for 150ms) to identify the last prompt.
|
|
377
|
+
const promptFound = await waitForFinalPrompt(session, PROMPT_PATTERN, preCommandOffset, timeoutMs);
|
|
378
|
+
if (promptFound < 0) {
|
|
379
|
+
throw new QuickCHRError(
|
|
380
|
+
"BOOT_TIMEOUT",
|
|
381
|
+
`Console exec: no prompt after command (timed out after ${timeoutMs}ms)`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Extract output: everything between our command and the next prompt.
|
|
386
|
+
// promptFound points at "] > " — back up to the newline before the prompt
|
|
387
|
+
// line so we don't include the "[admin@...] > " prefix in output.
|
|
388
|
+
const lastNewline = session.buffer.lastIndexOf("\n", promptFound);
|
|
389
|
+
const sliceEnd = lastNewline >= preCommandOffset ? lastNewline : promptFound;
|
|
390
|
+
const rawOutput = session.buffer.slice(preCommandOffset, sliceEnd);
|
|
391
|
+
|
|
392
|
+
// Clean up: strip ANSI, strip the command echo line, trim
|
|
393
|
+
const cleaned = stripAnsi(rawOutput);
|
|
394
|
+
const lines = cleaned.split(/\r?\n/);
|
|
395
|
+
|
|
396
|
+
// First non-empty line(s) are the command echo — skip them.
|
|
397
|
+
// The echo repeats back what we typed.
|
|
398
|
+
let startIdx = 0;
|
|
399
|
+
for (let i = 0; i < lines.length; i++) {
|
|
400
|
+
const line = lines[i];
|
|
401
|
+
if (!line) continue;
|
|
402
|
+
const trimmed = line.trim();
|
|
403
|
+
// Skip echo lines that contain our command text
|
|
404
|
+
if (trimmed.includes(command.trim()) || trimmed === "") {
|
|
405
|
+
startIdx = i + 1;
|
|
406
|
+
} else {
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Also strip trailing prompt fragments and empty lines
|
|
412
|
+
let endIdx = lines.length;
|
|
413
|
+
for (let i = lines.length - 1; i >= startIdx; i--) {
|
|
414
|
+
const line = lines[i];
|
|
415
|
+
if (!line) continue;
|
|
416
|
+
const trimmed = line.trim();
|
|
417
|
+
if (trimmed === "" || trimmed.endsWith(PROMPT_PATTERN.trim())) {
|
|
418
|
+
endIdx = i;
|
|
419
|
+
} else {
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const output = lines.slice(startIdx, endIdx).join("\n").trim();
|
|
425
|
+
return { output };
|
|
426
|
+
} finally {
|
|
427
|
+
session.socket.destroy();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Check whether the serial console is responsive.
|
|
433
|
+
*
|
|
434
|
+
* Connects, sends \r, and checks for a CLI prompt or Login: prompt.
|
|
435
|
+
* Returns "ready" if at CLI prompt, "login" if at login prompt, or false.
|
|
436
|
+
*/
|
|
437
|
+
export async function isConsoleReady(
|
|
438
|
+
machineDir: string,
|
|
439
|
+
timeoutMs: number = 5000,
|
|
440
|
+
portBase?: number,
|
|
441
|
+
): Promise<"ready" | "login" | false> {
|
|
442
|
+
let session: ConsoleSessionState | undefined;
|
|
443
|
+
try {
|
|
444
|
+
session = openSession(machineDir, portBase);
|
|
445
|
+
|
|
446
|
+
await new Promise<void>((resolve, reject) => {
|
|
447
|
+
session?.socket.once("connect", resolve);
|
|
448
|
+
session?.socket.once("error", reject);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Send \r to solicit response
|
|
452
|
+
write(session, "\r");
|
|
453
|
+
|
|
454
|
+
const deadline = Date.now() + timeoutMs;
|
|
455
|
+
while (Date.now() < deadline) {
|
|
456
|
+
const buf = session.buffer;
|
|
457
|
+
if (buf.indexOf(PROMPT_PATTERN, session.matchOffset) >= 0) {
|
|
458
|
+
return "ready";
|
|
459
|
+
}
|
|
460
|
+
if (buf.indexOf("Login:", session.matchOffset) >= 0) {
|
|
461
|
+
return "login";
|
|
462
|
+
}
|
|
463
|
+
if (session.streamDone) return false;
|
|
464
|
+
await Bun.sleep(POLL_INTERVAL_MS);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return false;
|
|
468
|
+
} catch {
|
|
469
|
+
return false;
|
|
470
|
+
} finally {
|
|
471
|
+
session?.socket.destroy();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential storage for quickchr.
|
|
3
|
+
*
|
|
4
|
+
* Two separate credential scopes:
|
|
5
|
+
*
|
|
6
|
+
* 1. **MikroTik web account** — mikrotik.com login for CHR trial licensing.
|
|
7
|
+
* One set of credentials, no per-machine variation.
|
|
8
|
+
* Env vars: MIKROTIK_WEB_ACCOUNT / MIKROTIK_WEB_PASSWORD.
|
|
9
|
+
*
|
|
10
|
+
* 2. **Instance credentials** — per-CHR username/password for REST API, exec, SSH.
|
|
11
|
+
* Stored per machine name. Created automatically during provisioning (the
|
|
12
|
+
* `quickchr` managed account) or explicitly via --user/--password.
|
|
13
|
+
*
|
|
14
|
+
* Both scopes use the secrets.ts wrapper (Bun.secrets → config file fallback).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { secretGet, secretSet, secretDelete, secretStorageLabel, secretGetSync, secretSetSync, secretDeleteSync } from "./secrets.ts";
|
|
18
|
+
|
|
19
|
+
export interface MikrotikCredentials {
|
|
20
|
+
account: string;
|
|
21
|
+
password: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface InstanceCredentials {
|
|
25
|
+
user: string;
|
|
26
|
+
password: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const WEB_SERVICE = "com.quickchr.mikrotik-web";
|
|
30
|
+
const INSTANCE_SERVICE = "com.quickchr.instance";
|
|
31
|
+
|
|
32
|
+
// --- MikroTik Web Account (licensing) ---
|
|
33
|
+
|
|
34
|
+
/** Read MikroTik web account credentials from env vars or secret store.
|
|
35
|
+
* Returns null when no credentials are found anywhere. */
|
|
36
|
+
export async function getStoredCredentials(): Promise<MikrotikCredentials | null> {
|
|
37
|
+
// Env vars — highest priority. New names first, legacy fallback.
|
|
38
|
+
const envAccount = process.env.MIKROTIK_WEB_ACCOUNT;
|
|
39
|
+
const envPassword = process.env.MIKROTIK_WEB_PASSWORD;
|
|
40
|
+
if (envAccount && envPassword) {
|
|
41
|
+
return { account: envAccount, password: envPassword };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const account = await secretGet(WEB_SERVICE, "account");
|
|
45
|
+
const password = await secretGet(WEB_SERVICE, "password");
|
|
46
|
+
if (account && password) {
|
|
47
|
+
return { account, password };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Save MikroTik web account credentials to the secret store. */
|
|
53
|
+
export async function saveCredentials(account: string, password: string): Promise<void> {
|
|
54
|
+
await secretSet(WEB_SERVICE, "account", account);
|
|
55
|
+
await secretSet(WEB_SERVICE, "password", password);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Delete stored MikroTik web account credentials. */
|
|
59
|
+
export async function deleteCredentials(): Promise<void> {
|
|
60
|
+
await secretDelete(WEB_SERVICE, "account");
|
|
61
|
+
await secretDelete(WEB_SERVICE, "password");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Describe where credentials are stored (for user-facing output). */
|
|
65
|
+
export function credentialStorageLabel(): string {
|
|
66
|
+
return secretStorageLabel();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Per-Instance Credentials (CHR login) ---
|
|
70
|
+
|
|
71
|
+
/** Read stored credentials for a CHR instance. Returns null if none saved.
|
|
72
|
+
* Uses config file storage (not OS keychain) — instance credentials are ephemeral
|
|
73
|
+
* and don't require keychain-level security. Avoids blocking macOS auth dialogs
|
|
74
|
+
* in non-interactive contexts (tests, background processes). */
|
|
75
|
+
export function getInstanceCredentials(machineName: string): InstanceCredentials | null {
|
|
76
|
+
const raw = secretGetSync(INSTANCE_SERVICE, machineName);
|
|
77
|
+
if (!raw) return null;
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(raw) as { user?: string; password?: string };
|
|
80
|
+
if (parsed.user !== undefined && parsed.password !== undefined) {
|
|
81
|
+
return { user: parsed.user, password: parsed.password };
|
|
82
|
+
}
|
|
83
|
+
} catch { /* corrupt entry */ }
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Save credentials for a CHR instance (config file, not keychain). */
|
|
88
|
+
export function saveInstanceCredentials(
|
|
89
|
+
machineName: string,
|
|
90
|
+
user: string,
|
|
91
|
+
password: string,
|
|
92
|
+
): void {
|
|
93
|
+
secretSetSync(INSTANCE_SERVICE, machineName, JSON.stringify({ user, password }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Delete stored credentials for a CHR instance (config file, not keychain). */
|
|
97
|
+
export function deleteInstanceCredentials(machineName: string): void {
|
|
98
|
+
secretDeleteSync(INSTANCE_SERVICE, machineName);
|
|
99
|
+
}
|