@usex/mikrotik-mcp 3.35.0 → 3.37.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/dist/cli.js +3 -3
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -3
- package/dist/shared/{cli-ymyndemx.js → cli-9p1cxztb.js} +259 -154
- package/dist/shared/{cli-8xwvyzks.js → cli-fpfchpjm.js} +1 -1
- package/dist/shared/{library-q35fq2gz.js → library-9pt0yeqy.js} +320 -154
- package/dist/shared/{library-ttqfk3a5.js → library-r6dx5hw2.js} +1 -1
- package/dist/ui/observability.html +1 -1
- package/package.json +2 -2
|
@@ -903,6 +903,29 @@ class MikroTikSSHClient {
|
|
|
903
903
|
});
|
|
904
904
|
});
|
|
905
905
|
}
|
|
906
|
+
downloadFile(remotePath) {
|
|
907
|
+
if (!this.client) {
|
|
908
|
+
return Promise.reject(new Error("Not connected to MikroTik device"));
|
|
909
|
+
}
|
|
910
|
+
const openSftp = this.client.sftp.bind(this.client);
|
|
911
|
+
return new Promise((resolve2, reject) => {
|
|
912
|
+
openSftp((err, sftp) => {
|
|
913
|
+
if (err) {
|
|
914
|
+
reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
sftp.readFile(remotePath, (rerr, data) => {
|
|
918
|
+
try {
|
|
919
|
+
sftp.end();
|
|
920
|
+
} catch {}
|
|
921
|
+
if (rerr)
|
|
922
|
+
reject(new Error(`SFTP read failed: ${rerr.message}`));
|
|
923
|
+
else
|
|
924
|
+
resolve2(data);
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
}
|
|
906
929
|
shell(opts = {}) {
|
|
907
930
|
if (!this.client) {
|
|
908
931
|
return Promise.reject(new Error("Not connected to MikroTik device"));
|
|
@@ -1048,16 +1071,7 @@ class SafeModeManager {
|
|
|
1048
1071
|
if (dc.mac) {
|
|
1049
1072
|
return "Error: Safe Mode is not supported for a MAC-Telnet device " + `('${this.deviceName}' is reached by MAC ${dc.mac}). ` + "Connect over SSH (configure host/credentials) to use Safe Mode.";
|
|
1050
1073
|
}
|
|
1051
|
-
const ssh = new MikroTikSSHClient({
|
|
1052
|
-
host: dc.host,
|
|
1053
|
-
username: dc.username,
|
|
1054
|
-
password: dc.password,
|
|
1055
|
-
keyFilename: dc.keyFilename,
|
|
1056
|
-
privateKey: dc.privateKey,
|
|
1057
|
-
keyPassphrase: dc.keyPassphrase,
|
|
1058
|
-
port: dc.port,
|
|
1059
|
-
timeoutMs: dc.timeoutMs
|
|
1060
|
-
});
|
|
1074
|
+
const ssh = new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
|
|
1061
1075
|
if (!await ssh.connect()) {
|
|
1062
1076
|
return "Error: Failed to connect to MikroTik device for safe mode session.";
|
|
1063
1077
|
}
|
|
@@ -1251,16 +1265,7 @@ async function uploadFileToDevice(deviceName, remotePath, data) {
|
|
|
1251
1265
|
if (isMacTelnetDevice(dc)) {
|
|
1252
1266
|
throw new Error(`Cannot transfer a file to '${name}': it is reached over Layer-2 MAC-Telnet, which has no file ` + "transfer. Configure SSH (host + credentials) for this device, or have the router pull the file " + "itself with /tool fetch from a URL it can reach.");
|
|
1253
1267
|
}
|
|
1254
|
-
const ssh = new MikroTikSSHClient({
|
|
1255
|
-
host: dc.host,
|
|
1256
|
-
username: dc.username,
|
|
1257
|
-
password: dc.password,
|
|
1258
|
-
keyFilename: dc.keyFilename,
|
|
1259
|
-
privateKey: dc.privateKey,
|
|
1260
|
-
keyPassphrase: dc.keyPassphrase,
|
|
1261
|
-
port: dc.port,
|
|
1262
|
-
timeoutMs: dc.timeoutMs
|
|
1263
|
-
});
|
|
1268
|
+
const ssh = new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
|
|
1264
1269
|
if (!await ssh.connect()) {
|
|
1265
1270
|
throw new Error(connectErrorMessage(name, dc, ssh.lastError));
|
|
1266
1271
|
}
|
|
@@ -1270,6 +1275,22 @@ async function uploadFileToDevice(deviceName, remotePath, data) {
|
|
|
1270
1275
|
ssh.disconnect();
|
|
1271
1276
|
}
|
|
1272
1277
|
}
|
|
1278
|
+
async function downloadFileFromDevice(deviceName, remotePath) {
|
|
1279
|
+
const name = resolveDeviceName(deviceName);
|
|
1280
|
+
const dc = getDevice(deviceName);
|
|
1281
|
+
if (isMacTelnetDevice(dc)) {
|
|
1282
|
+
throw new Error(`Cannot download a file from '${name}': it is reached over Layer-2 MAC-Telnet, which has no ` + "file transfer. Configure SSH (host + credentials) for this device, or use a text export " + "(stdout mode) instead of a binary backup.");
|
|
1283
|
+
}
|
|
1284
|
+
const ssh = new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
|
|
1285
|
+
if (!await ssh.connect()) {
|
|
1286
|
+
throw new Error(connectErrorMessage(name, dc, ssh.lastError));
|
|
1287
|
+
}
|
|
1288
|
+
try {
|
|
1289
|
+
return await ssh.downloadFile(remotePath);
|
|
1290
|
+
} finally {
|
|
1291
|
+
ssh.disconnect();
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1273
1294
|
|
|
1274
1295
|
// src/core/registry.ts
|
|
1275
1296
|
import { z as z2 } from "zod";
|
|
@@ -1483,6 +1504,67 @@ function parseCertExpiry(detail, nowMs) {
|
|
|
1483
1504
|
}
|
|
1484
1505
|
return out;
|
|
1485
1506
|
}
|
|
1507
|
+
function parseSize(s) {
|
|
1508
|
+
if (!s)
|
|
1509
|
+
return;
|
|
1510
|
+
const m = s.trim().match(/^([\d.]+)\s*([KMGT]i?B|B)?$/i);
|
|
1511
|
+
if (!m)
|
|
1512
|
+
return;
|
|
1513
|
+
const val = Number.parseFloat(m[1]);
|
|
1514
|
+
if (!Number.isFinite(val))
|
|
1515
|
+
return;
|
|
1516
|
+
const mult = {
|
|
1517
|
+
B: 1,
|
|
1518
|
+
KIB: 1024,
|
|
1519
|
+
MIB: 1024 ** 2,
|
|
1520
|
+
GIB: 1024 ** 3,
|
|
1521
|
+
TIB: 1024 ** 4,
|
|
1522
|
+
KB: 1000,
|
|
1523
|
+
MB: 1e6,
|
|
1524
|
+
GB: 1e9,
|
|
1525
|
+
TB: 1000000000000
|
|
1526
|
+
};
|
|
1527
|
+
return val * (mult[(m[2] ?? "B").toUpperCase()] ?? 1);
|
|
1528
|
+
}
|
|
1529
|
+
function parsePercent(s) {
|
|
1530
|
+
if (!s)
|
|
1531
|
+
return;
|
|
1532
|
+
const m = s.trim().match(/^([\d.]+)\s*%?$/);
|
|
1533
|
+
if (!m)
|
|
1534
|
+
return;
|
|
1535
|
+
const v = Number.parseFloat(m[1]);
|
|
1536
|
+
return Number.isFinite(v) ? v : undefined;
|
|
1537
|
+
}
|
|
1538
|
+
function usedPct(total, free) {
|
|
1539
|
+
if (total == null || free == null || total <= 0)
|
|
1540
|
+
return;
|
|
1541
|
+
return Math.max(0, Math.min(100, (total - free) / total * 100));
|
|
1542
|
+
}
|
|
1543
|
+
function parseSystemResource(text) {
|
|
1544
|
+
const r = parseKeyValues(text);
|
|
1545
|
+
const totalMemory = parseSize(r["total-memory"]);
|
|
1546
|
+
const freeMemory = parseSize(r["free-memory"]);
|
|
1547
|
+
const totalHdd = parseSize(r["total-hdd-space"]);
|
|
1548
|
+
const freeHdd = parseSize(r["free-hdd-space"]);
|
|
1549
|
+
const cpuLoad = parsePercent(r["cpu-load"]);
|
|
1550
|
+
const cpuCount = Number.parseInt(r["cpu-count"] ?? "", 10);
|
|
1551
|
+
const out = {
|
|
1552
|
+
version: r.version || undefined,
|
|
1553
|
+
boardName: r["board-name"] || undefined,
|
|
1554
|
+
architecture: r["architecture-name"] || undefined,
|
|
1555
|
+
cpuCount: Number.isFinite(cpuCount) ? cpuCount : undefined,
|
|
1556
|
+
cpuLoad,
|
|
1557
|
+
freeMemory,
|
|
1558
|
+
totalMemory,
|
|
1559
|
+
memUsedPct: usedPct(totalMemory, freeMemory),
|
|
1560
|
+
freeHdd,
|
|
1561
|
+
totalHdd,
|
|
1562
|
+
hddUsedPct: usedPct(totalHdd, freeHdd),
|
|
1563
|
+
uptime: r.uptime || undefined
|
|
1564
|
+
};
|
|
1565
|
+
const gotMetric = out.cpuLoad != null || out.totalMemory != null || out.totalHdd != null || out.version != null || out.uptime != null;
|
|
1566
|
+
return gotMetric ? out : null;
|
|
1567
|
+
}
|
|
1486
1568
|
function parseFlagLegend(text) {
|
|
1487
1569
|
const out = {};
|
|
1488
1570
|
const line = text.split(`
|
|
@@ -2692,6 +2774,158 @@ ${rows.slice(0, 40).map((r) => ` \u2022 ${r.name ?? "?"} (${r.type ?? "?"})${r.
|
|
|
2692
2774
|
// src/tools/backup.ts
|
|
2693
2775
|
import { z as z5 } from "zod";
|
|
2694
2776
|
|
|
2777
|
+
// src/backups/disk-check.ts
|
|
2778
|
+
var DISK_THRESHOLD_PCT = 90;
|
|
2779
|
+
async function checkDiskSpace(ctx) {
|
|
2780
|
+
try {
|
|
2781
|
+
const raw = await executeMikrotikCommand("/system resource print", ctx);
|
|
2782
|
+
const sys = parseSystemResource(raw);
|
|
2783
|
+
if (!sys || sys.hddUsedPct == null) {
|
|
2784
|
+
return { low: false };
|
|
2785
|
+
}
|
|
2786
|
+
return {
|
|
2787
|
+
low: sys.hddUsedPct >= DISK_THRESHOLD_PCT,
|
|
2788
|
+
usedPct: sys.hddUsedPct,
|
|
2789
|
+
freeBytes: sys.freeHdd,
|
|
2790
|
+
totalBytes: sys.totalHdd
|
|
2791
|
+
};
|
|
2792
|
+
} catch {
|
|
2793
|
+
return { low: false };
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
// src/backups/vault.ts
|
|
2798
|
+
import {
|
|
2799
|
+
existsSync as existsSync2,
|
|
2800
|
+
mkdirSync,
|
|
2801
|
+
readFileSync as readFileSync4,
|
|
2802
|
+
readdirSync,
|
|
2803
|
+
renameSync,
|
|
2804
|
+
rmSync,
|
|
2805
|
+
statSync,
|
|
2806
|
+
writeFileSync
|
|
2807
|
+
} from "fs";
|
|
2808
|
+
import { basename, join as join4 } from "path";
|
|
2809
|
+
function backupDir() {
|
|
2810
|
+
if (process.env.MIKROTIK_BACKUP_DIR)
|
|
2811
|
+
return process.env.MIKROTIK_BACKUP_DIR;
|
|
2812
|
+
try {
|
|
2813
|
+
const dir = getConfig().backupDir;
|
|
2814
|
+
if (dir)
|
|
2815
|
+
return dir;
|
|
2816
|
+
} catch {}
|
|
2817
|
+
return DEFAULT_BACKUP_DIR;
|
|
2818
|
+
}
|
|
2819
|
+
function safeName(name) {
|
|
2820
|
+
const base = basename(name);
|
|
2821
|
+
if (base !== name || base === "." || base === ".." || !/^[A-Za-z0-9._-]+$/.test(base)) {
|
|
2822
|
+
throw new Error(`invalid backup name: ${name}`);
|
|
2823
|
+
}
|
|
2824
|
+
return base;
|
|
2825
|
+
}
|
|
2826
|
+
function listBackups() {
|
|
2827
|
+
const dir = backupDir();
|
|
2828
|
+
if (!existsSync2(dir))
|
|
2829
|
+
return [];
|
|
2830
|
+
return readdirSync(dir).filter((f) => f.endsWith(".rsc") || f.endsWith(".backup")).map((f) => {
|
|
2831
|
+
const st = statSync(join4(dir, f));
|
|
2832
|
+
const us = f.indexOf("_");
|
|
2833
|
+
return {
|
|
2834
|
+
name: f,
|
|
2835
|
+
bytes: st.size,
|
|
2836
|
+
modified: st.mtimeMs,
|
|
2837
|
+
device: us > 0 ? f.slice(0, us) : undefined,
|
|
2838
|
+
type: f.endsWith(".backup") ? "backup" : "rsc"
|
|
2839
|
+
};
|
|
2840
|
+
}).sort((a, b) => b.modified - a.modified);
|
|
2841
|
+
}
|
|
2842
|
+
function readBackup(name) {
|
|
2843
|
+
return readFileSync4(join4(backupDir(), safeName(name)), "utf8");
|
|
2844
|
+
}
|
|
2845
|
+
function writeBackup(name, content) {
|
|
2846
|
+
const dir = backupDir();
|
|
2847
|
+
mkdirSync(dir, { recursive: true });
|
|
2848
|
+
let final = safeName(name);
|
|
2849
|
+
if (existsSync2(join4(dir, final))) {
|
|
2850
|
+
const dot = final.lastIndexOf(".");
|
|
2851
|
+
const stem = dot > 0 ? final.slice(0, dot) : final;
|
|
2852
|
+
const ext = dot > 0 ? final.slice(dot) : "";
|
|
2853
|
+
let n = 2;
|
|
2854
|
+
while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
|
|
2855
|
+
n++;
|
|
2856
|
+
final = `${stem}_${n}${ext}`;
|
|
2857
|
+
}
|
|
2858
|
+
writeFileSync(join4(dir, final), content, "utf8");
|
|
2859
|
+
return final;
|
|
2860
|
+
}
|
|
2861
|
+
function writeBinaryBackup(name, data) {
|
|
2862
|
+
const dir = backupDir();
|
|
2863
|
+
mkdirSync(dir, { recursive: true });
|
|
2864
|
+
let final = safeName(name);
|
|
2865
|
+
if (existsSync2(join4(dir, final))) {
|
|
2866
|
+
const dot = final.lastIndexOf(".");
|
|
2867
|
+
const stem = dot > 0 ? final.slice(0, dot) : final;
|
|
2868
|
+
const ext = dot > 0 ? final.slice(dot) : "";
|
|
2869
|
+
let n = 2;
|
|
2870
|
+
while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
|
|
2871
|
+
n++;
|
|
2872
|
+
final = `${stem}_${n}${ext}`;
|
|
2873
|
+
}
|
|
2874
|
+
writeFileSync(join4(dir, final), data);
|
|
2875
|
+
return final;
|
|
2876
|
+
}
|
|
2877
|
+
function deleteBackup(name) {
|
|
2878
|
+
const p = join4(backupDir(), safeName(name));
|
|
2879
|
+
if (!existsSync2(p))
|
|
2880
|
+
return false;
|
|
2881
|
+
rmSync(p);
|
|
2882
|
+
return true;
|
|
2883
|
+
}
|
|
2884
|
+
function renameBackup(oldName, newName) {
|
|
2885
|
+
const dir = backupDir();
|
|
2886
|
+
const from = join4(dir, safeName(oldName));
|
|
2887
|
+
let to = safeName(newName);
|
|
2888
|
+
if (!to.endsWith(".rsc"))
|
|
2889
|
+
to += ".rsc";
|
|
2890
|
+
const toPath = join4(dir, to);
|
|
2891
|
+
if (!existsSync2(from))
|
|
2892
|
+
throw new Error(`backup not found: ${oldName}`);
|
|
2893
|
+
if (existsSync2(toPath))
|
|
2894
|
+
throw new Error(`a backup named '${to}' already exists`);
|
|
2895
|
+
renameSync(from, toPath);
|
|
2896
|
+
return to;
|
|
2897
|
+
}
|
|
2898
|
+
function exportToCommands(text) {
|
|
2899
|
+
const lines = [];
|
|
2900
|
+
let buf = "";
|
|
2901
|
+
for (const raw of text.split(`
|
|
2902
|
+
`)) {
|
|
2903
|
+
const line = raw.replace(/\r$/, "");
|
|
2904
|
+
buf = buf ? `${buf} ${line.trim()}` : line;
|
|
2905
|
+
if (buf.trimEnd().endsWith("\\")) {
|
|
2906
|
+
buf = buf.trimEnd().slice(0, -1).trimEnd();
|
|
2907
|
+
continue;
|
|
2908
|
+
}
|
|
2909
|
+
lines.push(buf);
|
|
2910
|
+
buf = "";
|
|
2911
|
+
}
|
|
2912
|
+
if (buf)
|
|
2913
|
+
lines.push(buf);
|
|
2914
|
+
const cmds = [];
|
|
2915
|
+
let section = "";
|
|
2916
|
+
for (const line of lines) {
|
|
2917
|
+
const t = line.trim();
|
|
2918
|
+
if (!t || t.startsWith("#"))
|
|
2919
|
+
continue;
|
|
2920
|
+
if (t.startsWith("/")) {
|
|
2921
|
+
section = t;
|
|
2922
|
+
continue;
|
|
2923
|
+
}
|
|
2924
|
+
cmds.push(section ? `${section} ${t}` : t);
|
|
2925
|
+
}
|
|
2926
|
+
return cmds;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2695
2929
|
// src/core/datestamp.ts
|
|
2696
2930
|
var MONTHS2 = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
2697
2931
|
function pad(n) {
|
|
@@ -2764,6 +2998,12 @@ async function deviceDateStamp(ctx) {
|
|
|
2764
2998
|
return hostStamp(new Date);
|
|
2765
2999
|
}
|
|
2766
3000
|
|
|
3001
|
+
// src/core/slug.ts
|
|
3002
|
+
function deviceSlug(name) {
|
|
3003
|
+
const s = (name ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3004
|
+
return s || "device";
|
|
3005
|
+
}
|
|
3006
|
+
|
|
2767
3007
|
// src/tools/backup.ts
|
|
2768
3008
|
var backupTools = [
|
|
2769
3009
|
defineTool({
|
|
@@ -2780,6 +3020,7 @@ var backupTools = [
|
|
|
2780
3020
|
async handler(a, ctx) {
|
|
2781
3021
|
const name = a.name || `backup_${await deviceDateStamp(ctx)}`;
|
|
2782
3022
|
ctx.info(`Creating backup: name=${name}`);
|
|
3023
|
+
const disk = await checkDiskSpace(ctx);
|
|
2783
3024
|
const cmd = new Cmd("/system backup save").set("name", name);
|
|
2784
3025
|
if (a.dont_encrypt)
|
|
2785
3026
|
cmd.raw("dont-encrypt=yes");
|
|
@@ -2788,13 +3029,35 @@ var backupTools = [
|
|
|
2788
3029
|
if (!a.include_password)
|
|
2789
3030
|
cmd.raw("password-file=no");
|
|
2790
3031
|
const result = await executeMikrotikCommand(cmd.build(), ctx);
|
|
2791
|
-
if (result.includes("saved") || result.trim() === "") {
|
|
2792
|
-
|
|
2793
|
-
|
|
3032
|
+
if (!(result.includes("saved") || result.trim() === "")) {
|
|
3033
|
+
if (disk.low) {
|
|
3034
|
+
return `Failed to create backup (device disk is ${disk.usedPct?.toFixed(0)}% used): ${result}
|
|
2794
3035
|
|
|
2795
|
-
|
|
3036
|
+
` + "Tip: use create_local_backup or create_export instead \u2014 text exports can be captured " + "directly to the local vault without using any device disk space.";
|
|
3037
|
+
}
|
|
3038
|
+
return `Failed to create backup: ${result}`;
|
|
3039
|
+
}
|
|
3040
|
+
if (disk.low) {
|
|
3041
|
+
const dc = getDevice(ctx.device);
|
|
3042
|
+
if (isMacTelnetDevice(dc)) {
|
|
3043
|
+
return `Backup '${name}.backup' created on device, but disk is ${disk.usedPct?.toFixed(0)}% used. ` + "This is a MAC-Telnet device (no SFTP), so the file cannot be downloaded automatically. " + "Free disk space manually or use create_local_backup for a text export that bypasses device storage.";
|
|
3044
|
+
}
|
|
3045
|
+
const deviceFile = `${name}.backup`;
|
|
3046
|
+
try {
|
|
3047
|
+
ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 downloading backup to local vault`);
|
|
3048
|
+
const data = await downloadFileFromDevice(ctx.device, deviceFile);
|
|
3049
|
+
const device = resolveDeviceName(ctx.device);
|
|
3050
|
+
const vaultName = writeBinaryBackup(`${deviceSlug(device)}_${deviceFile}`, data);
|
|
3051
|
+
await executeMikrotikCommand(`/file remove ${deviceFile}`, ctx);
|
|
3052
|
+
return `[LOW DISK \u2014 ${disk.usedPct?.toFixed(0)}% used] Backup '${deviceFile}' created, downloaded to ` + `local vault as '${vaultName}' (${data.length} bytes) at ${backupDir()}, and removed from ` + "the device to free disk space.";
|
|
3053
|
+
} catch (e) {
|
|
3054
|
+
return `Backup '${name}.backup' created on device, but the automatic download-and-cleanup ` + `failed: ${e instanceof Error ? e.message : String(e)}. The file is still on the device.`;
|
|
3055
|
+
}
|
|
2796
3056
|
}
|
|
2797
|
-
|
|
3057
|
+
const fileDetails = await executeMikrotikCommand(`/file print detail where name=${name}.backup`, ctx);
|
|
3058
|
+
return fileDetails ? `Backup created successfully:
|
|
3059
|
+
|
|
3060
|
+
${fileDetails}` : `Backup '${name}.backup' created successfully.`;
|
|
2798
3061
|
}
|
|
2799
3062
|
}),
|
|
2800
3063
|
defineTool({
|
|
@@ -2843,6 +3106,21 @@ ${result}`;
|
|
|
2843
3106
|
async handler(a, ctx) {
|
|
2844
3107
|
const name = a.name || `export_${await deviceDateStamp(ctx)}`;
|
|
2845
3108
|
ctx.info(`Creating export: name=${name}, format=${a.file_format}`);
|
|
3109
|
+
const disk = await checkDiskSpace(ctx);
|
|
3110
|
+
if (disk.low) {
|
|
3111
|
+
ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 redirecting export to local vault`);
|
|
3112
|
+
const cmd2 = new Cmd("/export");
|
|
3113
|
+
cmd2.raw(a.verbose ? "verbose" : null);
|
|
3114
|
+
cmd2.raw(a.compact ? "compact" : null);
|
|
3115
|
+
cmd2.raw(!a.hide_sensitive ? "show-sensitive" : null);
|
|
3116
|
+
const body = await executeMikrotikCommand(cmd2.build(), ctx);
|
|
3117
|
+
if (isEmpty(body) || looksLikeError(body)) {
|
|
3118
|
+
return `Failed to create export: ${body}`;
|
|
3119
|
+
}
|
|
3120
|
+
const device = resolveDeviceName(ctx.device);
|
|
3121
|
+
const vaultName = writeBackup(`${deviceSlug(device)}_${name}.rsc`, body);
|
|
3122
|
+
return `[LOW DISK \u2014 ${disk.usedPct?.toFixed(0)}% used] Export saved to LOCAL VAULT as '${vaultName}' ` + `(${Buffer.byteLength(body)} bytes) at ${backupDir()} \u2014 no file was written to the device.`;
|
|
3123
|
+
}
|
|
2846
3124
|
const extension = a.file_format === "json" || a.file_format === "xml" ? a.file_format : "rsc";
|
|
2847
3125
|
const fullName = `${name}.${extension}`;
|
|
2848
3126
|
const cmd = new Cmd("/export");
|
|
@@ -2881,6 +3159,20 @@ ${fileDetails}` : `Export '${fullName}' created successfully.`;
|
|
|
2881
3159
|
name = `export_${cleanSection}_${await deviceDateStamp(ctx)}`;
|
|
2882
3160
|
}
|
|
2883
3161
|
ctx.info(`Exporting section: section=${a.section}, name=${name}`);
|
|
3162
|
+
const disk = await checkDiskSpace(ctx);
|
|
3163
|
+
if (disk.low) {
|
|
3164
|
+
ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 redirecting section export to local vault`);
|
|
3165
|
+
const cmd2 = new Cmd(`/${a.section} export`);
|
|
3166
|
+
cmd2.raw(!a.hide_sensitive ? "show-sensitive" : null);
|
|
3167
|
+
cmd2.raw(a.compact ? "compact" : null);
|
|
3168
|
+
const body = await executeMikrotikCommand(cmd2.build(), ctx);
|
|
3169
|
+
if (isEmpty(body) || looksLikeError(body)) {
|
|
3170
|
+
return `Failed to export section: ${body}`;
|
|
3171
|
+
}
|
|
3172
|
+
const device = resolveDeviceName(ctx.device);
|
|
3173
|
+
const vaultName = writeBackup(`${deviceSlug(device)}_${name}.rsc`, body);
|
|
3174
|
+
return `[LOW DISK \u2014 ${disk.usedPct?.toFixed(0)}% used] Section export saved to LOCAL VAULT as ` + `'${vaultName}' (${Buffer.byteLength(body)} bytes) at ${backupDir()} \u2014 no file was written ` + "to the device.";
|
|
3175
|
+
}
|
|
2884
3176
|
const cmd = new Cmd(`/${a.section} export`).set("file", name);
|
|
2885
3177
|
cmd.raw(!a.hide_sensitive ? "show-sensitive" : null);
|
|
2886
3178
|
cmd.raw(a.compact ? "compact" : null);
|
|
@@ -3031,127 +3323,6 @@ ${result}`;
|
|
|
3031
3323
|
// src/tools/local-backup.ts
|
|
3032
3324
|
import { z as z6 } from "zod";
|
|
3033
3325
|
|
|
3034
|
-
// src/core/slug.ts
|
|
3035
|
-
function deviceSlug(name) {
|
|
3036
|
-
const s = (name ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3037
|
-
return s || "device";
|
|
3038
|
-
}
|
|
3039
|
-
|
|
3040
|
-
// src/backups/vault.ts
|
|
3041
|
-
import {
|
|
3042
|
-
existsSync as existsSync2,
|
|
3043
|
-
mkdirSync,
|
|
3044
|
-
readFileSync as readFileSync4,
|
|
3045
|
-
readdirSync,
|
|
3046
|
-
renameSync,
|
|
3047
|
-
rmSync,
|
|
3048
|
-
statSync,
|
|
3049
|
-
writeFileSync
|
|
3050
|
-
} from "fs";
|
|
3051
|
-
import { basename, join as join4 } from "path";
|
|
3052
|
-
function backupDir() {
|
|
3053
|
-
if (process.env.MIKROTIK_BACKUP_DIR)
|
|
3054
|
-
return process.env.MIKROTIK_BACKUP_DIR;
|
|
3055
|
-
try {
|
|
3056
|
-
const dir = getConfig().backupDir;
|
|
3057
|
-
if (dir)
|
|
3058
|
-
return dir;
|
|
3059
|
-
} catch {}
|
|
3060
|
-
return DEFAULT_BACKUP_DIR;
|
|
3061
|
-
}
|
|
3062
|
-
function safeName(name) {
|
|
3063
|
-
const base = basename(name);
|
|
3064
|
-
if (base !== name || base === "." || base === ".." || !/^[A-Za-z0-9._-]+$/.test(base)) {
|
|
3065
|
-
throw new Error(`invalid backup name: ${name}`);
|
|
3066
|
-
}
|
|
3067
|
-
return base;
|
|
3068
|
-
}
|
|
3069
|
-
function listBackups() {
|
|
3070
|
-
const dir = backupDir();
|
|
3071
|
-
if (!existsSync2(dir))
|
|
3072
|
-
return [];
|
|
3073
|
-
return readdirSync(dir).filter((f) => f.endsWith(".rsc")).map((f) => {
|
|
3074
|
-
const st = statSync(join4(dir, f));
|
|
3075
|
-
const us = f.indexOf("_");
|
|
3076
|
-
return {
|
|
3077
|
-
name: f,
|
|
3078
|
-
bytes: st.size,
|
|
3079
|
-
modified: st.mtimeMs,
|
|
3080
|
-
device: us > 0 ? f.slice(0, us) : undefined
|
|
3081
|
-
};
|
|
3082
|
-
}).sort((a, b) => b.modified - a.modified);
|
|
3083
|
-
}
|
|
3084
|
-
function readBackup(name) {
|
|
3085
|
-
return readFileSync4(join4(backupDir(), safeName(name)), "utf8");
|
|
3086
|
-
}
|
|
3087
|
-
function writeBackup(name, content) {
|
|
3088
|
-
const dir = backupDir();
|
|
3089
|
-
mkdirSync(dir, { recursive: true });
|
|
3090
|
-
let final = safeName(name);
|
|
3091
|
-
if (existsSync2(join4(dir, final))) {
|
|
3092
|
-
const dot = final.lastIndexOf(".");
|
|
3093
|
-
const stem = dot > 0 ? final.slice(0, dot) : final;
|
|
3094
|
-
const ext = dot > 0 ? final.slice(dot) : "";
|
|
3095
|
-
let n = 2;
|
|
3096
|
-
while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
|
|
3097
|
-
n++;
|
|
3098
|
-
final = `${stem}_${n}${ext}`;
|
|
3099
|
-
}
|
|
3100
|
-
writeFileSync(join4(dir, final), content, "utf8");
|
|
3101
|
-
return final;
|
|
3102
|
-
}
|
|
3103
|
-
function deleteBackup(name) {
|
|
3104
|
-
const p = join4(backupDir(), safeName(name));
|
|
3105
|
-
if (!existsSync2(p))
|
|
3106
|
-
return false;
|
|
3107
|
-
rmSync(p);
|
|
3108
|
-
return true;
|
|
3109
|
-
}
|
|
3110
|
-
function renameBackup(oldName, newName) {
|
|
3111
|
-
const dir = backupDir();
|
|
3112
|
-
const from = join4(dir, safeName(oldName));
|
|
3113
|
-
let to = safeName(newName);
|
|
3114
|
-
if (!to.endsWith(".rsc"))
|
|
3115
|
-
to += ".rsc";
|
|
3116
|
-
const toPath = join4(dir, to);
|
|
3117
|
-
if (!existsSync2(from))
|
|
3118
|
-
throw new Error(`backup not found: ${oldName}`);
|
|
3119
|
-
if (existsSync2(toPath))
|
|
3120
|
-
throw new Error(`a backup named '${to}' already exists`);
|
|
3121
|
-
renameSync(from, toPath);
|
|
3122
|
-
return to;
|
|
3123
|
-
}
|
|
3124
|
-
function exportToCommands(text) {
|
|
3125
|
-
const lines = [];
|
|
3126
|
-
let buf = "";
|
|
3127
|
-
for (const raw of text.split(`
|
|
3128
|
-
`)) {
|
|
3129
|
-
const line = raw.replace(/\r$/, "");
|
|
3130
|
-
buf = buf ? `${buf} ${line.trim()}` : line;
|
|
3131
|
-
if (buf.trimEnd().endsWith("\\")) {
|
|
3132
|
-
buf = buf.trimEnd().slice(0, -1).trimEnd();
|
|
3133
|
-
continue;
|
|
3134
|
-
}
|
|
3135
|
-
lines.push(buf);
|
|
3136
|
-
buf = "";
|
|
3137
|
-
}
|
|
3138
|
-
if (buf)
|
|
3139
|
-
lines.push(buf);
|
|
3140
|
-
const cmds = [];
|
|
3141
|
-
let section = "";
|
|
3142
|
-
for (const line of lines) {
|
|
3143
|
-
const t = line.trim();
|
|
3144
|
-
if (!t || t.startsWith("#"))
|
|
3145
|
-
continue;
|
|
3146
|
-
if (t.startsWith("/")) {
|
|
3147
|
-
section = t;
|
|
3148
|
-
continue;
|
|
3149
|
-
}
|
|
3150
|
-
cmds.push(section ? `${section} ${t}` : t);
|
|
3151
|
-
}
|
|
3152
|
-
return cmds;
|
|
3153
|
-
}
|
|
3154
|
-
|
|
3155
3326
|
// src/backups/create.ts
|
|
3156
3327
|
function labelSlug(label) {
|
|
3157
3328
|
const s = (label ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
@@ -5946,7 +6117,7 @@ var cache = null;
|
|
|
5946
6117
|
async function gateway() {
|
|
5947
6118
|
if (cache)
|
|
5948
6119
|
return cache;
|
|
5949
|
-
const { moduleCatalog } = await import("./library-
|
|
6120
|
+
const { moduleCatalog } = await import("./library-r6dx5hw2.js");
|
|
5950
6121
|
const forIndex = [];
|
|
5951
6122
|
const byName = new Map;
|
|
5952
6123
|
for (const mod of moduleCatalog) {
|
|
@@ -13957,13 +14128,8 @@ var sshTestTools = [
|
|
|
13957
14128
|
}
|
|
13958
14129
|
ctx.info(`Testing SSH from MCP host to configured device '${name}' (${dc.host}:${dc.port})`);
|
|
13959
14130
|
return probe({
|
|
13960
|
-
|
|
13961
|
-
|
|
13962
|
-
username: dc.username,
|
|
13963
|
-
password: dc.password,
|
|
13964
|
-
keyFilename: dc.keyFilename,
|
|
13965
|
-
privateKey: dc.privateKey,
|
|
13966
|
-
keyPassphrase: dc.keyPassphrase,
|
|
14131
|
+
...sshOptionsOf(dc),
|
|
14132
|
+
jump: resolveJump(dc),
|
|
13967
14133
|
timeoutMs: Math.min(dc.timeoutMs ?? 1e4, 1e4)
|
|
13968
14134
|
}, `'${name}' (${dc.host}:${dc.port})`, "/system identity print");
|
|
13969
14135
|
}
|