@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,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-boot provisioning — user creation, admin disable via REST API,
|
|
3
|
+
* with a console transport fallback when REST is unavailable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdirSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { QuickCHRError } from "./types.ts";
|
|
9
|
+
import { saveInstanceCredentials } from "./credentials.ts";
|
|
10
|
+
import { generatePassword } from "./password.ts";
|
|
11
|
+
import { waitForBoot } from "./qemu.ts";
|
|
12
|
+
import { consoleExec } from "./console.ts";
|
|
13
|
+
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
14
|
+
import { restGet, restPost, restPatch } from "./rest.ts";
|
|
15
|
+
|
|
16
|
+
/** The default user name created by quickchr for managed CHR access. */
|
|
17
|
+
export const QUICKCHR_USER = "quickchr";
|
|
18
|
+
|
|
19
|
+
export interface ProvisionResult {
|
|
20
|
+
/** The user that was created (or null if none). */
|
|
21
|
+
user: { name: string; password: string } | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function readUser(httpPort: number, auth: string, name: string): Promise<Record<string, unknown> | undefined> {
|
|
25
|
+
// Fetch the full user list and filter client-side. RouterOS REST filter
|
|
26
|
+
// query syntax differs across versions and is unreliable for name-based
|
|
27
|
+
// lookups; fetching all users is safe given the small number of entries.
|
|
28
|
+
const { status, body } = await restGet(
|
|
29
|
+
`http://127.0.0.1:${httpPort}/rest/user`,
|
|
30
|
+
auth,
|
|
31
|
+
5000,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (status < 200 || status >= 300) {
|
|
35
|
+
throw new QuickCHRError(
|
|
36
|
+
"PROCESS_FAILED",
|
|
37
|
+
`Failed to read user list: HTTP ${status} — ${body}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let users: Record<string, unknown>[];
|
|
42
|
+
try { users = JSON.parse(body) as Record<string, unknown>[]; } catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(users)) return undefined;
|
|
46
|
+
return users.find((u) => u.name === name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Wait for the REST API to be fully ready on a fresh (unprovisioned) CHR.
|
|
50
|
+
*
|
|
51
|
+
* Delegates to the shared waitForBoot which guards against the startup race
|
|
52
|
+
* (wrong body briefly after boot) and requires two consecutive stable
|
|
53
|
+
* responses before declaring boot complete. */
|
|
54
|
+
async function waitForRest(
|
|
55
|
+
httpPort: number,
|
|
56
|
+
timeoutMs: number = 60_000,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
// Provisioning always runs on a fresh CHR — use admin: (empty password)
|
|
59
|
+
const booted = await waitForBoot(httpPort, timeoutMs, `Basic ${btoa("admin:")}`);
|
|
60
|
+
if (!booted) {
|
|
61
|
+
throw new QuickCHRError("BOOT_TIMEOUT", "REST API did not become available");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Create a user via the REST API. */
|
|
66
|
+
export async function createUser(
|
|
67
|
+
httpPort: number,
|
|
68
|
+
name: string,
|
|
69
|
+
password: string,
|
|
70
|
+
group: string = "full",
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
await waitForRest(httpPort);
|
|
73
|
+
|
|
74
|
+
const auth = `Basic ${btoa("admin:")}`;
|
|
75
|
+
const { status, body: respBody } = await restPost(
|
|
76
|
+
`http://127.0.0.1:${httpPort}/rest/user/add`,
|
|
77
|
+
auth,
|
|
78
|
+
{ name, password, group },
|
|
79
|
+
10_000,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (status < 200 || status >= 300) {
|
|
83
|
+
throw new QuickCHRError(
|
|
84
|
+
"PROCESS_FAILED",
|
|
85
|
+
`Failed to create user "${name}": HTTP ${status} — ${respBody}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// RouterOS can acknowledge /user/add before the user record becomes visible.
|
|
90
|
+
// Verify visibility (and group) so callers get deterministic behavior.
|
|
91
|
+
const expectedGroup = group.trim().toLowerCase();
|
|
92
|
+
const deadline = Date.now() + 30_000;
|
|
93
|
+
while (Date.now() < deadline) {
|
|
94
|
+
try {
|
|
95
|
+
const user = await readUser(httpPort, auth, name);
|
|
96
|
+
if (user) {
|
|
97
|
+
const actualGroup = String(user.group ?? "").trim().toLowerCase();
|
|
98
|
+
if (!actualGroup) {
|
|
99
|
+
await Bun.sleep(500);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (actualGroup !== expectedGroup) {
|
|
103
|
+
throw new QuickCHRError(
|
|
104
|
+
"PROCESS_FAILED",
|
|
105
|
+
`User "${name}" created with unexpected group (expected=${expectedGroup}, actual=${actualGroup})`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// Rethrow deliberate group mismatch errors; retry transient HTTP failures
|
|
112
|
+
if (e instanceof QuickCHRError && e.message.includes("unexpected group")) throw e;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await Bun.sleep(500);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
throw new QuickCHRError(
|
|
119
|
+
"PROCESS_FAILED",
|
|
120
|
+
`User "${name}" creation was acknowledged but did not become visible in RouterOS`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Disable the admin user via the REST API. */
|
|
125
|
+
export async function disableAdmin(httpPort: number, verifyAuth?: string): Promise<void> {
|
|
126
|
+
await waitForRest(httpPort);
|
|
127
|
+
|
|
128
|
+
const adminAuth = `Basic ${btoa("admin:")}`;
|
|
129
|
+
|
|
130
|
+
// Prefer alternate credentials when available — RouterOS silently ignores
|
|
131
|
+
// a user disabling itself via REST PATCH (returns 200 but no-ops).
|
|
132
|
+
// The newly-created user with "full" group can disable admin reliably.
|
|
133
|
+
const actionAuth = verifyAuth ?? adminAuth;
|
|
134
|
+
|
|
135
|
+
// Look up admin's internal .id (e.g. "*1") so we can target it precisely.
|
|
136
|
+
// RouterOS REST action endpoints interpret `numbers` as position/ID, not name,
|
|
137
|
+
// so `numbers: "admin"` silently no-ops. PATCH by .id is reliable: the `*`
|
|
138
|
+
// in the ID is a sub-delimiter (RFC 3986 path segments) and is NOT
|
|
139
|
+
// percent-encoded by the WHATWG URL constructor or Bun's fetch.
|
|
140
|
+
//
|
|
141
|
+
// Retry the lookup: the /rest/user endpoint can return wrong data or HTTP 500
|
|
142
|
+
// briefly after boot (startup race). Poll until we get a valid admin record.
|
|
143
|
+
let adminUser: Record<string, unknown> | undefined;
|
|
144
|
+
const lookupDeadline = Date.now() + 15_000;
|
|
145
|
+
while (Date.now() < lookupDeadline) {
|
|
146
|
+
try {
|
|
147
|
+
adminUser = await readUser(httpPort, actionAuth, "admin");
|
|
148
|
+
if (adminUser?.[".id"]) break;
|
|
149
|
+
} catch {
|
|
150
|
+
// Transient error (HTTP 500 during startup, auth propagation) — retry
|
|
151
|
+
}
|
|
152
|
+
await Bun.sleep(500);
|
|
153
|
+
}
|
|
154
|
+
if (!adminUser) {
|
|
155
|
+
throw new QuickCHRError("PROCESS_FAILED", "Cannot disable admin: user not found in user list after 15s of retries");
|
|
156
|
+
}
|
|
157
|
+
const adminId = String(adminUser[".id"] ?? "");
|
|
158
|
+
if (!adminId) {
|
|
159
|
+
throw new QuickCHRError("PROCESS_FAILED", "Cannot disable admin: user record has no .id");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const { status: patchStatus, body: patchRawBody } = await restPatch(
|
|
163
|
+
`http://127.0.0.1:${httpPort}/rest/user/${adminId}`,
|
|
164
|
+
actionAuth,
|
|
165
|
+
{ disabled: "yes" },
|
|
166
|
+
10_000,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (patchStatus < 200 || patchStatus >= 300) {
|
|
170
|
+
throw new QuickCHRError(
|
|
171
|
+
"PROCESS_FAILED",
|
|
172
|
+
`Failed to disable admin: HTTP ${patchStatus} — ${patchRawBody}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Validate the PATCH response body — RouterOS returns the updated user object.
|
|
177
|
+
// If the response confirms disabled=true, we know the change was applied.
|
|
178
|
+
// If the response body is unexpected (boot race), fall through to verification loop.
|
|
179
|
+
let patchConfirmed = false;
|
|
180
|
+
try {
|
|
181
|
+
const patchBody = JSON.parse(patchRawBody) as Record<string, unknown>;
|
|
182
|
+
const patchDisabled = String(patchBody?.disabled ?? "");
|
|
183
|
+
patchConfirmed = patchDisabled === "true" || patchDisabled === "yes";
|
|
184
|
+
} catch { /* non-JSON body — fall through to verification loop */ }
|
|
185
|
+
|
|
186
|
+
if (patchConfirmed) return;
|
|
187
|
+
|
|
188
|
+
// PATCH didn't confirm the change in its body — poll until the change is visible.
|
|
189
|
+
const readAuth = verifyAuth ?? adminAuth;
|
|
190
|
+
let lastDiag = "";
|
|
191
|
+
const deadline = Date.now() + 45_000;
|
|
192
|
+
while (Date.now() < deadline) {
|
|
193
|
+
// Primary: read admin user via alternate creds and check the disabled field.
|
|
194
|
+
try {
|
|
195
|
+
const user = await readUser(httpPort, readAuth, "admin");
|
|
196
|
+
const d = String(user?.disabled ?? "");
|
|
197
|
+
if (d === "yes" || d === "true") return;
|
|
198
|
+
lastDiag = `readUser(auth=${verifyAuth ? "verifyAuth" : "adminAuth"}) returned disabled=${JSON.stringify(user?.disabled)}`;
|
|
199
|
+
} catch (e) {
|
|
200
|
+
lastDiag = `readUser threw: ${e instanceof Error ? e.message : String(e)}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Secondary: if admin credentials get 401, admin auth is rejected = disabled.
|
|
204
|
+
try {
|
|
205
|
+
const { status: probeStatus } = await restGet(
|
|
206
|
+
`http://127.0.0.1:${httpPort}/rest/system/resource`,
|
|
207
|
+
adminAuth,
|
|
208
|
+
3000,
|
|
209
|
+
);
|
|
210
|
+
if (probeStatus === 401) return;
|
|
211
|
+
} catch { /* ignore */ }
|
|
212
|
+
|
|
213
|
+
await Bun.sleep(500);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
throw new QuickCHRError(
|
|
217
|
+
"PROCESS_FAILED",
|
|
218
|
+
`Admin disable timed out after 45s — admin user is still enabled. Last diagnostic: ${lastDiag}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Provision a CHR via the serial console — fallback when REST is unavailable.
|
|
223
|
+
* Creates a user and optionally disables admin by sending RouterOS CLI commands
|
|
224
|
+
* over the serial socket. All commands run as admin with an empty password (fresh CHR). */
|
|
225
|
+
async function consoleProvision(
|
|
226
|
+
machineDir: string,
|
|
227
|
+
machineName: string,
|
|
228
|
+
effectiveUser: { name: string; password: string } | null,
|
|
229
|
+
shouldDisableAdmin: boolean,
|
|
230
|
+
logger?: ProgressLogger,
|
|
231
|
+
portBase?: number,
|
|
232
|
+
): Promise<ProvisionResult> {
|
|
233
|
+
const log = logger ?? createLogger();
|
|
234
|
+
|
|
235
|
+
if (effectiveUser) {
|
|
236
|
+
log.status(` Creating user "${effectiveUser.name}" via serial console...`);
|
|
237
|
+
// RouterOS CLI syntax: /user add name=... password=... group=full
|
|
238
|
+
await consoleExec(
|
|
239
|
+
machineDir,
|
|
240
|
+
`/user add name="${effectiveUser.name}" password="${effectiveUser.password}" group=full`,
|
|
241
|
+
"admin",
|
|
242
|
+
"",
|
|
243
|
+
30_000,
|
|
244
|
+
portBase,
|
|
245
|
+
);
|
|
246
|
+
saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
|
|
247
|
+
log.debug(`User "${effectiveUser.name}" created via console`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (shouldDisableAdmin) {
|
|
251
|
+
log.status(" Disabling admin via serial console...");
|
|
252
|
+
// Use newly-created user to disable admin — RouterOS silently ignores
|
|
253
|
+
// disabling your own account while the current session is authenticated as that user.
|
|
254
|
+
const disableUser = effectiveUser?.name ?? "admin";
|
|
255
|
+
const disablePass = effectiveUser?.password ?? "";
|
|
256
|
+
await consoleExec(machineDir, "/user set [find name=admin] disabled=yes", disableUser, disablePass, 30_000, portBase);
|
|
257
|
+
log.debug("Admin disabled via console");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { user: effectiveUser };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Generate an ed25519 SSH keypair and install the public key on the CHR for `username`.
|
|
265
|
+
* Keys are stored in `<machineDir>/ssh/id_ed25519` (private) and `.pub` (public).
|
|
266
|
+
* This enables SSH transport without passwords for the quickchr managed user.
|
|
267
|
+
*/
|
|
268
|
+
export async function installSshKey(
|
|
269
|
+
httpPort: number,
|
|
270
|
+
username: string,
|
|
271
|
+
machineName: string,
|
|
272
|
+
machineDir: string,
|
|
273
|
+
portBase?: number,
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
const sshDir = join(machineDir, "ssh");
|
|
276
|
+
mkdirSync(sshDir, { recursive: true });
|
|
277
|
+
|
|
278
|
+
const privateKeyPath = join(sshDir, "id_ed25519");
|
|
279
|
+
|
|
280
|
+
const keygen = Bun.spawnSync(
|
|
281
|
+
["ssh-keygen", "-t", "ed25519", "-f", privateKeyPath, "-N", "", "-C", `quickchr@${machineName}`],
|
|
282
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
283
|
+
);
|
|
284
|
+
if (keygen.exitCode !== 0) {
|
|
285
|
+
throw new Error(`ssh-keygen failed: ${new TextDecoder().decode(keygen.stderr)}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const pubKey = (await Bun.file(`${privateKeyPath}.pub`).text()).trim();
|
|
289
|
+
|
|
290
|
+
// Install via serial console — commits synchronously unlike the REST endpoint
|
|
291
|
+
// which may return 200 OK before the key is durable in RouterOS storage.
|
|
292
|
+
await consoleExec(
|
|
293
|
+
machineDir,
|
|
294
|
+
`/user/ssh-keys/add user="${username}" key="${pubKey}"`,
|
|
295
|
+
"admin",
|
|
296
|
+
"",
|
|
297
|
+
30_000,
|
|
298
|
+
portBase,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// Verify the key appears in the REST listing.
|
|
302
|
+
const auth = `Basic ${btoa("admin:")}`;
|
|
303
|
+
const deadline = Date.now() + 10_000;
|
|
304
|
+
let lastListBody = "";
|
|
305
|
+
while (Date.now() < deadline) {
|
|
306
|
+
try {
|
|
307
|
+
const { status, body: listBody } = await restGet(
|
|
308
|
+
`http://127.0.0.1:${httpPort}/rest/user/ssh-keys`,
|
|
309
|
+
auth,
|
|
310
|
+
5_000,
|
|
311
|
+
);
|
|
312
|
+
if (status >= 200 && status < 300) {
|
|
313
|
+
lastListBody = listBody;
|
|
314
|
+
const keys = JSON.parse(listBody) as Array<{ user?: string }>;
|
|
315
|
+
if (Array.isArray(keys) && keys.some((k) => k.user === username)) return;
|
|
316
|
+
} else {
|
|
317
|
+
lastListBody = `HTTP ${status}`;
|
|
318
|
+
}
|
|
319
|
+
} catch (e) {
|
|
320
|
+
lastListBody = String(e);
|
|
321
|
+
}
|
|
322
|
+
await Bun.sleep(500);
|
|
323
|
+
}
|
|
324
|
+
throw new Error(`SSH key for ${username} installed via console but did not appear in REST listing within 10s (last: ${lastListBody})`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Run all provisioning steps based on the config.
|
|
328
|
+
*
|
|
329
|
+
* When `user` is provided, creates that exact user. When `user` is omitted
|
|
330
|
+
* but `secureLogin` is true (or unset — it defaults to true), a `quickchr`
|
|
331
|
+
* managed account is created automatically with a generated password.
|
|
332
|
+
*
|
|
333
|
+
* When `machineDir` is provided and the REST API does not become available within
|
|
334
|
+
* 30 seconds, provisioning falls back to the serial console transport.
|
|
335
|
+
*
|
|
336
|
+
* Returns the user that was created (if any) so callers can display the
|
|
337
|
+
* password and store it.
|
|
338
|
+
*/
|
|
339
|
+
export async function provision(
|
|
340
|
+
httpPort: number,
|
|
341
|
+
machineName: string,
|
|
342
|
+
user?: { name: string; password: string },
|
|
343
|
+
shouldDisableAdmin?: boolean,
|
|
344
|
+
secureLogin?: boolean,
|
|
345
|
+
logger?: ProgressLogger,
|
|
346
|
+
machineDir?: string,
|
|
347
|
+
portBase?: number,
|
|
348
|
+
): Promise<ProvisionResult> {
|
|
349
|
+
const log = logger ?? createLogger();
|
|
350
|
+
|
|
351
|
+
// Determine what user to create.
|
|
352
|
+
// Priority: explicit user > auto-create quickchr account (secureLogin must be explicit true)
|
|
353
|
+
let effectiveUser = user ?? null;
|
|
354
|
+
if (!effectiveUser && secureLogin === true) {
|
|
355
|
+
effectiveUser = { name: QUICKCHR_USER, password: generatePassword() };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!effectiveUser && !shouldDisableAdmin) {
|
|
359
|
+
// No user, no disable — save admin:"" as instance creds for symmetry
|
|
360
|
+
saveInstanceCredentials(machineName, "admin", "");
|
|
361
|
+
return { user: null };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Try REST provisioning. When machineDir is provided, use a shorter timeout
|
|
365
|
+
// so the console fallback can engage sooner if REST isn't available.
|
|
366
|
+
const restTimeout = machineDir ? 30_000 : 60_000;
|
|
367
|
+
let usedConsolePath = false;
|
|
368
|
+
try {
|
|
369
|
+
await waitForRest(httpPort, restTimeout);
|
|
370
|
+
} catch (e) {
|
|
371
|
+
if (machineDir) {
|
|
372
|
+
log.warn("REST API unavailable — falling back to serial console provisioning");
|
|
373
|
+
const result = await consoleProvision(machineDir, machineName, effectiveUser, !!shouldDisableAdmin, logger, portBase);
|
|
374
|
+
usedConsolePath = true;
|
|
375
|
+
// After console provisioning the machine is booted; REST comes up shortly.
|
|
376
|
+
// Attempt SSH key install for the managed quickchr user — non-fatal if REST is still unavailable.
|
|
377
|
+
if (secureLogin && !user && effectiveUser) {
|
|
378
|
+
try {
|
|
379
|
+
await waitForRest(httpPort, 30_000);
|
|
380
|
+
await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
|
|
381
|
+
} catch (keyErr) {
|
|
382
|
+
log.warn(`SSH key install failed after console provisioning (SSH transport will fall back to password): ${keyErr}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
throw e;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!usedConsolePath) {
|
|
391
|
+
if (effectiveUser) {
|
|
392
|
+
await createUser(httpPort, effectiveUser.name, effectiveUser.password);
|
|
393
|
+
// Persist to secret store so resolveAuth() picks it up
|
|
394
|
+
saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (secureLogin && !user && effectiveUser && machineDir) {
|
|
398
|
+
// Brief pause so RouterOS propagates the new user to all subsystems
|
|
399
|
+
// (including the SSH key store) before we attempt key installation.
|
|
400
|
+
await Bun.sleep(1000);
|
|
401
|
+
try {
|
|
402
|
+
await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
|
|
403
|
+
} catch (e) {
|
|
404
|
+
log.warn(`SSH key install failed (SSH transport will fall back to password): ${e}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (shouldDisableAdmin) {
|
|
409
|
+
if (!effectiveUser) {
|
|
410
|
+
log.warn("Warning: disabling admin without creating another user — you may lose access");
|
|
411
|
+
}
|
|
412
|
+
// Pass the new user's auth for verification — after disabling admin,
|
|
413
|
+
// admin creds may stop working for REST queries.
|
|
414
|
+
const verifyAuth = effectiveUser
|
|
415
|
+
? `Basic ${btoa(`${effectiveUser.name}:${effectiveUser.password}`)}`
|
|
416
|
+
: undefined;
|
|
417
|
+
await disableAdmin(httpPort, verifyAuth);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { user: effectiveUser };
|
|
422
|
+
}
|