@usex/mikrotik-mcp 3.36.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 +2 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -2
- package/dist/shared/{cli-3580mf22.js → cli-9p1cxztb.js} +255 -127
- package/dist/shared/{cli-ckbcx5hs.js → cli-fpfchpjm.js} +1 -1
- package/dist/shared/{library-jm3khjrr.js → library-9pt0yeqy.js} +316 -127
- package/dist/shared/{library-jrp4dawm.js → library-r6dx5hw2.js} +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -84,7 +84,7 @@ import {
|
|
|
84
84
|
toggleAaaEntity,
|
|
85
85
|
updateAaaEntity,
|
|
86
86
|
writeBackup
|
|
87
|
-
} from "./shared/cli-
|
|
87
|
+
} from "./shared/cli-9p1cxztb.js";
|
|
88
88
|
|
|
89
89
|
// src/cli.ts
|
|
90
90
|
import { existsSync as existsSync2 } from "fs";
|
|
@@ -2233,7 +2233,7 @@ function registerPrompts(server) {
|
|
|
2233
2233
|
// package.json
|
|
2234
2234
|
var package_default = {
|
|
2235
2235
|
name: "@usex/mikrotik-mcp",
|
|
2236
|
-
version: "3.
|
|
2236
|
+
version: "3.37.0",
|
|
2237
2237
|
description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
|
|
2238
2238
|
keywords: [
|
|
2239
2239
|
"ai",
|
package/dist/index.d.ts
CHANGED
|
@@ -240,6 +240,12 @@ declare class MikroTikSSHClient {
|
|
|
240
240
|
* Resolves on success; rejects with a clear reason on failure.
|
|
241
241
|
*/
|
|
242
242
|
uploadFile(remotePath: string, data: Buffer): Promise<void>;
|
|
243
|
+
/**
|
|
244
|
+
* Download a file's bytes from the device over SFTP. `remotePath` is relative
|
|
245
|
+
* to the SFTP default directory (the flash root). Resolves with the raw file
|
|
246
|
+
* contents; rejects with a clear reason on failure.
|
|
247
|
+
*/
|
|
248
|
+
downloadFile(remotePath: string): Promise<Buffer>;
|
|
243
249
|
/** Open a persistent interactive shell channel (used by Safe Mode). */
|
|
244
250
|
shell(opts?: {
|
|
245
251
|
term?: string;
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
resolveDeviceName,
|
|
22
22
|
selectToolModules,
|
|
23
23
|
setConfig
|
|
24
|
-
} from "./shared/library-
|
|
24
|
+
} from "./shared/library-9pt0yeqy.js";
|
|
25
25
|
// src/server.ts
|
|
26
26
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
27
27
|
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -131,7 +131,7 @@ function registerPrompts(server) {
|
|
|
131
131
|
// package.json
|
|
132
132
|
var package_default = {
|
|
133
133
|
name: "@usex/mikrotik-mcp",
|
|
134
|
-
version: "3.
|
|
134
|
+
version: "3.37.0",
|
|
135
135
|
description: "MCP server for MikroTik RouterOS \u2014 660+ tools over SSH for firewall, NAT, routing, DHCP, DNS, WireGuard, wireless, QoS and more.",
|
|
136
136
|
keywords: [
|
|
137
137
|
"ai",
|
|
@@ -906,6 +906,29 @@ class MikroTikSSHClient {
|
|
|
906
906
|
});
|
|
907
907
|
});
|
|
908
908
|
}
|
|
909
|
+
downloadFile(remotePath) {
|
|
910
|
+
if (!this.client) {
|
|
911
|
+
return Promise.reject(new Error("Not connected to MikroTik device"));
|
|
912
|
+
}
|
|
913
|
+
const openSftp = this.client.sftp.bind(this.client);
|
|
914
|
+
return new Promise((resolve2, reject) => {
|
|
915
|
+
openSftp((err, sftp) => {
|
|
916
|
+
if (err) {
|
|
917
|
+
reject(new Error(`SFTP subsystem unavailable: ${err.message}`));
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
sftp.readFile(remotePath, (rerr, data) => {
|
|
921
|
+
try {
|
|
922
|
+
sftp.end();
|
|
923
|
+
} catch {}
|
|
924
|
+
if (rerr)
|
|
925
|
+
reject(new Error(`SFTP read failed: ${rerr.message}`));
|
|
926
|
+
else
|
|
927
|
+
resolve2(data);
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
}
|
|
909
932
|
shell(opts = {}) {
|
|
910
933
|
if (!this.client) {
|
|
911
934
|
return Promise.reject(new Error("Not connected to MikroTik device"));
|
|
@@ -1258,6 +1281,22 @@ async function uploadFileToDevice(deviceName, remotePath, data) {
|
|
|
1258
1281
|
ssh.disconnect();
|
|
1259
1282
|
}
|
|
1260
1283
|
}
|
|
1284
|
+
async function downloadFileFromDevice(deviceName, remotePath) {
|
|
1285
|
+
const name = resolveDeviceName(deviceName);
|
|
1286
|
+
const dc = getDevice(deviceName);
|
|
1287
|
+
if (isMacTelnetDevice(dc)) {
|
|
1288
|
+
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.");
|
|
1289
|
+
}
|
|
1290
|
+
const ssh = new MikroTikSSHClient({ ...sshOptionsOf(dc), jump: resolveJump(dc) });
|
|
1291
|
+
if (!await ssh.connect()) {
|
|
1292
|
+
throw new Error(connectErrorMessage(name, dc, ssh.lastError));
|
|
1293
|
+
}
|
|
1294
|
+
try {
|
|
1295
|
+
return await ssh.downloadFile(remotePath);
|
|
1296
|
+
} finally {
|
|
1297
|
+
ssh.disconnect();
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1261
1300
|
|
|
1262
1301
|
// src/core/registry.ts
|
|
1263
1302
|
import { z as z2 } from "zod";
|
|
@@ -2759,6 +2798,158 @@ ${rows.slice(0, 40).map((r) => ` \u2022 ${r.name ?? "?"} (${r.type ?? "?"})${r.
|
|
|
2759
2798
|
// src/tools/backup.ts
|
|
2760
2799
|
import { z as z5 } from "zod";
|
|
2761
2800
|
|
|
2801
|
+
// src/backups/disk-check.ts
|
|
2802
|
+
var DISK_THRESHOLD_PCT = 90;
|
|
2803
|
+
async function checkDiskSpace(ctx) {
|
|
2804
|
+
try {
|
|
2805
|
+
const raw = await executeMikrotikCommand("/system resource print", ctx);
|
|
2806
|
+
const sys = parseSystemResource(raw);
|
|
2807
|
+
if (!sys || sys.hddUsedPct == null) {
|
|
2808
|
+
return { low: false };
|
|
2809
|
+
}
|
|
2810
|
+
return {
|
|
2811
|
+
low: sys.hddUsedPct >= DISK_THRESHOLD_PCT,
|
|
2812
|
+
usedPct: sys.hddUsedPct,
|
|
2813
|
+
freeBytes: sys.freeHdd,
|
|
2814
|
+
totalBytes: sys.totalHdd
|
|
2815
|
+
};
|
|
2816
|
+
} catch {
|
|
2817
|
+
return { low: false };
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
// src/backups/vault.ts
|
|
2822
|
+
import {
|
|
2823
|
+
existsSync as existsSync2,
|
|
2824
|
+
mkdirSync,
|
|
2825
|
+
readFileSync as readFileSync4,
|
|
2826
|
+
readdirSync,
|
|
2827
|
+
renameSync,
|
|
2828
|
+
rmSync,
|
|
2829
|
+
statSync,
|
|
2830
|
+
writeFileSync
|
|
2831
|
+
} from "fs";
|
|
2832
|
+
import { basename, join as join4 } from "path";
|
|
2833
|
+
function backupDir() {
|
|
2834
|
+
if (process.env.MIKROTIK_BACKUP_DIR)
|
|
2835
|
+
return process.env.MIKROTIK_BACKUP_DIR;
|
|
2836
|
+
try {
|
|
2837
|
+
const dir = getConfig().backupDir;
|
|
2838
|
+
if (dir)
|
|
2839
|
+
return dir;
|
|
2840
|
+
} catch {}
|
|
2841
|
+
return DEFAULT_BACKUP_DIR;
|
|
2842
|
+
}
|
|
2843
|
+
function safeName(name) {
|
|
2844
|
+
const base = basename(name);
|
|
2845
|
+
if (base !== name || base === "." || base === ".." || !/^[A-Za-z0-9._-]+$/.test(base)) {
|
|
2846
|
+
throw new Error(`invalid backup name: ${name}`);
|
|
2847
|
+
}
|
|
2848
|
+
return base;
|
|
2849
|
+
}
|
|
2850
|
+
function listBackups() {
|
|
2851
|
+
const dir = backupDir();
|
|
2852
|
+
if (!existsSync2(dir))
|
|
2853
|
+
return [];
|
|
2854
|
+
return readdirSync(dir).filter((f) => f.endsWith(".rsc") || f.endsWith(".backup")).map((f) => {
|
|
2855
|
+
const st = statSync(join4(dir, f));
|
|
2856
|
+
const us = f.indexOf("_");
|
|
2857
|
+
return {
|
|
2858
|
+
name: f,
|
|
2859
|
+
bytes: st.size,
|
|
2860
|
+
modified: st.mtimeMs,
|
|
2861
|
+
device: us > 0 ? f.slice(0, us) : undefined,
|
|
2862
|
+
type: f.endsWith(".backup") ? "backup" : "rsc"
|
|
2863
|
+
};
|
|
2864
|
+
}).sort((a, b) => b.modified - a.modified);
|
|
2865
|
+
}
|
|
2866
|
+
function readBackup(name) {
|
|
2867
|
+
return readFileSync4(join4(backupDir(), safeName(name)), "utf8");
|
|
2868
|
+
}
|
|
2869
|
+
function writeBackup(name, content) {
|
|
2870
|
+
const dir = backupDir();
|
|
2871
|
+
mkdirSync(dir, { recursive: true });
|
|
2872
|
+
let final = safeName(name);
|
|
2873
|
+
if (existsSync2(join4(dir, final))) {
|
|
2874
|
+
const dot = final.lastIndexOf(".");
|
|
2875
|
+
const stem = dot > 0 ? final.slice(0, dot) : final;
|
|
2876
|
+
const ext = dot > 0 ? final.slice(dot) : "";
|
|
2877
|
+
let n = 2;
|
|
2878
|
+
while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
|
|
2879
|
+
n++;
|
|
2880
|
+
final = `${stem}_${n}${ext}`;
|
|
2881
|
+
}
|
|
2882
|
+
writeFileSync(join4(dir, final), content, "utf8");
|
|
2883
|
+
return final;
|
|
2884
|
+
}
|
|
2885
|
+
function writeBinaryBackup(name, data) {
|
|
2886
|
+
const dir = backupDir();
|
|
2887
|
+
mkdirSync(dir, { recursive: true });
|
|
2888
|
+
let final = safeName(name);
|
|
2889
|
+
if (existsSync2(join4(dir, final))) {
|
|
2890
|
+
const dot = final.lastIndexOf(".");
|
|
2891
|
+
const stem = dot > 0 ? final.slice(0, dot) : final;
|
|
2892
|
+
const ext = dot > 0 ? final.slice(dot) : "";
|
|
2893
|
+
let n = 2;
|
|
2894
|
+
while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
|
|
2895
|
+
n++;
|
|
2896
|
+
final = `${stem}_${n}${ext}`;
|
|
2897
|
+
}
|
|
2898
|
+
writeFileSync(join4(dir, final), data);
|
|
2899
|
+
return final;
|
|
2900
|
+
}
|
|
2901
|
+
function deleteBackup(name) {
|
|
2902
|
+
const p = join4(backupDir(), safeName(name));
|
|
2903
|
+
if (!existsSync2(p))
|
|
2904
|
+
return false;
|
|
2905
|
+
rmSync(p);
|
|
2906
|
+
return true;
|
|
2907
|
+
}
|
|
2908
|
+
function renameBackup(oldName, newName) {
|
|
2909
|
+
const dir = backupDir();
|
|
2910
|
+
const from = join4(dir, safeName(oldName));
|
|
2911
|
+
let to = safeName(newName);
|
|
2912
|
+
if (!to.endsWith(".rsc"))
|
|
2913
|
+
to += ".rsc";
|
|
2914
|
+
const toPath = join4(dir, to);
|
|
2915
|
+
if (!existsSync2(from))
|
|
2916
|
+
throw new Error(`backup not found: ${oldName}`);
|
|
2917
|
+
if (existsSync2(toPath))
|
|
2918
|
+
throw new Error(`a backup named '${to}' already exists`);
|
|
2919
|
+
renameSync(from, toPath);
|
|
2920
|
+
return to;
|
|
2921
|
+
}
|
|
2922
|
+
function exportToCommands(text) {
|
|
2923
|
+
const lines = [];
|
|
2924
|
+
let buf = "";
|
|
2925
|
+
for (const raw of text.split(`
|
|
2926
|
+
`)) {
|
|
2927
|
+
const line = raw.replace(/\r$/, "");
|
|
2928
|
+
buf = buf ? `${buf} ${line.trim()}` : line;
|
|
2929
|
+
if (buf.trimEnd().endsWith("\\")) {
|
|
2930
|
+
buf = buf.trimEnd().slice(0, -1).trimEnd();
|
|
2931
|
+
continue;
|
|
2932
|
+
}
|
|
2933
|
+
lines.push(buf);
|
|
2934
|
+
buf = "";
|
|
2935
|
+
}
|
|
2936
|
+
if (buf)
|
|
2937
|
+
lines.push(buf);
|
|
2938
|
+
const cmds = [];
|
|
2939
|
+
let section = "";
|
|
2940
|
+
for (const line of lines) {
|
|
2941
|
+
const t = line.trim();
|
|
2942
|
+
if (!t || t.startsWith("#"))
|
|
2943
|
+
continue;
|
|
2944
|
+
if (t.startsWith("/")) {
|
|
2945
|
+
section = t;
|
|
2946
|
+
continue;
|
|
2947
|
+
}
|
|
2948
|
+
cmds.push(section ? `${section} ${t}` : t);
|
|
2949
|
+
}
|
|
2950
|
+
return cmds;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2762
2953
|
// src/core/datestamp.ts
|
|
2763
2954
|
var MONTHS2 = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
2764
2955
|
function pad(n) {
|
|
@@ -2831,6 +3022,12 @@ async function deviceDateStamp(ctx) {
|
|
|
2831
3022
|
return hostStamp(new Date);
|
|
2832
3023
|
}
|
|
2833
3024
|
|
|
3025
|
+
// src/core/slug.ts
|
|
3026
|
+
function deviceSlug(name) {
|
|
3027
|
+
const s = (name ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3028
|
+
return s || "device";
|
|
3029
|
+
}
|
|
3030
|
+
|
|
2834
3031
|
// src/tools/backup.ts
|
|
2835
3032
|
var backupTools = [
|
|
2836
3033
|
defineTool({
|
|
@@ -2847,6 +3044,7 @@ var backupTools = [
|
|
|
2847
3044
|
async handler(a, ctx) {
|
|
2848
3045
|
const name = a.name || `backup_${await deviceDateStamp(ctx)}`;
|
|
2849
3046
|
ctx.info(`Creating backup: name=${name}`);
|
|
3047
|
+
const disk = await checkDiskSpace(ctx);
|
|
2850
3048
|
const cmd = new Cmd("/system backup save").set("name", name);
|
|
2851
3049
|
if (a.dont_encrypt)
|
|
2852
3050
|
cmd.raw("dont-encrypt=yes");
|
|
@@ -2855,13 +3053,35 @@ var backupTools = [
|
|
|
2855
3053
|
if (!a.include_password)
|
|
2856
3054
|
cmd.raw("password-file=no");
|
|
2857
3055
|
const result = await executeMikrotikCommand(cmd.build(), ctx);
|
|
2858
|
-
if (result.includes("saved") || result.trim() === "") {
|
|
2859
|
-
|
|
2860
|
-
|
|
3056
|
+
if (!(result.includes("saved") || result.trim() === "")) {
|
|
3057
|
+
if (disk.low) {
|
|
3058
|
+
return `Failed to create backup (device disk is ${disk.usedPct?.toFixed(0)}% used): ${result}
|
|
2861
3059
|
|
|
2862
|
-
|
|
3060
|
+
` + "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.";
|
|
3061
|
+
}
|
|
3062
|
+
return `Failed to create backup: ${result}`;
|
|
3063
|
+
}
|
|
3064
|
+
if (disk.low) {
|
|
3065
|
+
const dc = getDevice(ctx.device);
|
|
3066
|
+
if (isMacTelnetDevice(dc)) {
|
|
3067
|
+
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.";
|
|
3068
|
+
}
|
|
3069
|
+
const deviceFile = `${name}.backup`;
|
|
3070
|
+
try {
|
|
3071
|
+
ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 downloading backup to local vault`);
|
|
3072
|
+
const data = await downloadFileFromDevice(ctx.device, deviceFile);
|
|
3073
|
+
const device = resolveDeviceName(ctx.device);
|
|
3074
|
+
const vaultName = writeBinaryBackup(`${deviceSlug(device)}_${deviceFile}`, data);
|
|
3075
|
+
await executeMikrotikCommand(`/file remove ${deviceFile}`, ctx);
|
|
3076
|
+
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.";
|
|
3077
|
+
} catch (e) {
|
|
3078
|
+
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.`;
|
|
3079
|
+
}
|
|
2863
3080
|
}
|
|
2864
|
-
|
|
3081
|
+
const fileDetails = await executeMikrotikCommand(`/file print detail where name=${name}.backup`, ctx);
|
|
3082
|
+
return fileDetails ? `Backup created successfully:
|
|
3083
|
+
|
|
3084
|
+
${fileDetails}` : `Backup '${name}.backup' created successfully.`;
|
|
2865
3085
|
}
|
|
2866
3086
|
}),
|
|
2867
3087
|
defineTool({
|
|
@@ -2910,6 +3130,21 @@ ${result}`;
|
|
|
2910
3130
|
async handler(a, ctx) {
|
|
2911
3131
|
const name = a.name || `export_${await deviceDateStamp(ctx)}`;
|
|
2912
3132
|
ctx.info(`Creating export: name=${name}, format=${a.file_format}`);
|
|
3133
|
+
const disk = await checkDiskSpace(ctx);
|
|
3134
|
+
if (disk.low) {
|
|
3135
|
+
ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 redirecting export to local vault`);
|
|
3136
|
+
const cmd2 = new Cmd("/export");
|
|
3137
|
+
cmd2.raw(a.verbose ? "verbose" : null);
|
|
3138
|
+
cmd2.raw(a.compact ? "compact" : null);
|
|
3139
|
+
cmd2.raw(!a.hide_sensitive ? "show-sensitive" : null);
|
|
3140
|
+
const body = await executeMikrotikCommand(cmd2.build(), ctx);
|
|
3141
|
+
if (isEmpty(body) || looksLikeError(body)) {
|
|
3142
|
+
return `Failed to create export: ${body}`;
|
|
3143
|
+
}
|
|
3144
|
+
const device = resolveDeviceName(ctx.device);
|
|
3145
|
+
const vaultName = writeBackup(`${deviceSlug(device)}_${name}.rsc`, body);
|
|
3146
|
+
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.`;
|
|
3147
|
+
}
|
|
2913
3148
|
const extension = a.file_format === "json" || a.file_format === "xml" ? a.file_format : "rsc";
|
|
2914
3149
|
const fullName = `${name}.${extension}`;
|
|
2915
3150
|
const cmd = new Cmd("/export");
|
|
@@ -2948,6 +3183,20 @@ ${fileDetails}` : `Export '${fullName}' created successfully.`;
|
|
|
2948
3183
|
name = `export_${cleanSection}_${await deviceDateStamp(ctx)}`;
|
|
2949
3184
|
}
|
|
2950
3185
|
ctx.info(`Exporting section: section=${a.section}, name=${name}`);
|
|
3186
|
+
const disk = await checkDiskSpace(ctx);
|
|
3187
|
+
if (disk.low) {
|
|
3188
|
+
ctx.info(`Low disk (${disk.usedPct?.toFixed(0)}%) \u2014 redirecting section export to local vault`);
|
|
3189
|
+
const cmd2 = new Cmd(`/${a.section} export`);
|
|
3190
|
+
cmd2.raw(!a.hide_sensitive ? "show-sensitive" : null);
|
|
3191
|
+
cmd2.raw(a.compact ? "compact" : null);
|
|
3192
|
+
const body = await executeMikrotikCommand(cmd2.build(), ctx);
|
|
3193
|
+
if (isEmpty(body) || looksLikeError(body)) {
|
|
3194
|
+
return `Failed to export section: ${body}`;
|
|
3195
|
+
}
|
|
3196
|
+
const device = resolveDeviceName(ctx.device);
|
|
3197
|
+
const vaultName = writeBackup(`${deviceSlug(device)}_${name}.rsc`, body);
|
|
3198
|
+
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.";
|
|
3199
|
+
}
|
|
2951
3200
|
const cmd = new Cmd(`/${a.section} export`).set("file", name);
|
|
2952
3201
|
cmd.raw(!a.hide_sensitive ? "show-sensitive" : null);
|
|
2953
3202
|
cmd.raw(a.compact ? "compact" : null);
|
|
@@ -3098,127 +3347,6 @@ ${result}`;
|
|
|
3098
3347
|
// src/tools/local-backup.ts
|
|
3099
3348
|
import { z as z6 } from "zod";
|
|
3100
3349
|
|
|
3101
|
-
// src/core/slug.ts
|
|
3102
|
-
function deviceSlug(name) {
|
|
3103
|
-
const s = (name ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3104
|
-
return s || "device";
|
|
3105
|
-
}
|
|
3106
|
-
|
|
3107
|
-
// src/backups/vault.ts
|
|
3108
|
-
import {
|
|
3109
|
-
existsSync as existsSync2,
|
|
3110
|
-
mkdirSync,
|
|
3111
|
-
readFileSync as readFileSync4,
|
|
3112
|
-
readdirSync,
|
|
3113
|
-
renameSync,
|
|
3114
|
-
rmSync,
|
|
3115
|
-
statSync,
|
|
3116
|
-
writeFileSync
|
|
3117
|
-
} from "fs";
|
|
3118
|
-
import { basename, join as join4 } from "path";
|
|
3119
|
-
function backupDir() {
|
|
3120
|
-
if (process.env.MIKROTIK_BACKUP_DIR)
|
|
3121
|
-
return process.env.MIKROTIK_BACKUP_DIR;
|
|
3122
|
-
try {
|
|
3123
|
-
const dir = getConfig().backupDir;
|
|
3124
|
-
if (dir)
|
|
3125
|
-
return dir;
|
|
3126
|
-
} catch {}
|
|
3127
|
-
return DEFAULT_BACKUP_DIR;
|
|
3128
|
-
}
|
|
3129
|
-
function safeName(name) {
|
|
3130
|
-
const base = basename(name);
|
|
3131
|
-
if (base !== name || base === "." || base === ".." || !/^[A-Za-z0-9._-]+$/.test(base)) {
|
|
3132
|
-
throw new Error(`invalid backup name: ${name}`);
|
|
3133
|
-
}
|
|
3134
|
-
return base;
|
|
3135
|
-
}
|
|
3136
|
-
function listBackups() {
|
|
3137
|
-
const dir = backupDir();
|
|
3138
|
-
if (!existsSync2(dir))
|
|
3139
|
-
return [];
|
|
3140
|
-
return readdirSync(dir).filter((f) => f.endsWith(".rsc")).map((f) => {
|
|
3141
|
-
const st = statSync(join4(dir, f));
|
|
3142
|
-
const us = f.indexOf("_");
|
|
3143
|
-
return {
|
|
3144
|
-
name: f,
|
|
3145
|
-
bytes: st.size,
|
|
3146
|
-
modified: st.mtimeMs,
|
|
3147
|
-
device: us > 0 ? f.slice(0, us) : undefined
|
|
3148
|
-
};
|
|
3149
|
-
}).sort((a, b) => b.modified - a.modified);
|
|
3150
|
-
}
|
|
3151
|
-
function readBackup(name) {
|
|
3152
|
-
return readFileSync4(join4(backupDir(), safeName(name)), "utf8");
|
|
3153
|
-
}
|
|
3154
|
-
function writeBackup(name, content) {
|
|
3155
|
-
const dir = backupDir();
|
|
3156
|
-
mkdirSync(dir, { recursive: true });
|
|
3157
|
-
let final = safeName(name);
|
|
3158
|
-
if (existsSync2(join4(dir, final))) {
|
|
3159
|
-
const dot = final.lastIndexOf(".");
|
|
3160
|
-
const stem = dot > 0 ? final.slice(0, dot) : final;
|
|
3161
|
-
const ext = dot > 0 ? final.slice(dot) : "";
|
|
3162
|
-
let n = 2;
|
|
3163
|
-
while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
|
|
3164
|
-
n++;
|
|
3165
|
-
final = `${stem}_${n}${ext}`;
|
|
3166
|
-
}
|
|
3167
|
-
writeFileSync(join4(dir, final), content, "utf8");
|
|
3168
|
-
return final;
|
|
3169
|
-
}
|
|
3170
|
-
function deleteBackup(name) {
|
|
3171
|
-
const p = join4(backupDir(), safeName(name));
|
|
3172
|
-
if (!existsSync2(p))
|
|
3173
|
-
return false;
|
|
3174
|
-
rmSync(p);
|
|
3175
|
-
return true;
|
|
3176
|
-
}
|
|
3177
|
-
function renameBackup(oldName, newName) {
|
|
3178
|
-
const dir = backupDir();
|
|
3179
|
-
const from = join4(dir, safeName(oldName));
|
|
3180
|
-
let to = safeName(newName);
|
|
3181
|
-
if (!to.endsWith(".rsc"))
|
|
3182
|
-
to += ".rsc";
|
|
3183
|
-
const toPath = join4(dir, to);
|
|
3184
|
-
if (!existsSync2(from))
|
|
3185
|
-
throw new Error(`backup not found: ${oldName}`);
|
|
3186
|
-
if (existsSync2(toPath))
|
|
3187
|
-
throw new Error(`a backup named '${to}' already exists`);
|
|
3188
|
-
renameSync(from, toPath);
|
|
3189
|
-
return to;
|
|
3190
|
-
}
|
|
3191
|
-
function exportToCommands(text) {
|
|
3192
|
-
const lines = [];
|
|
3193
|
-
let buf = "";
|
|
3194
|
-
for (const raw of text.split(`
|
|
3195
|
-
`)) {
|
|
3196
|
-
const line = raw.replace(/\r$/, "");
|
|
3197
|
-
buf = buf ? `${buf} ${line.trim()}` : line;
|
|
3198
|
-
if (buf.trimEnd().endsWith("\\")) {
|
|
3199
|
-
buf = buf.trimEnd().slice(0, -1).trimEnd();
|
|
3200
|
-
continue;
|
|
3201
|
-
}
|
|
3202
|
-
lines.push(buf);
|
|
3203
|
-
buf = "";
|
|
3204
|
-
}
|
|
3205
|
-
if (buf)
|
|
3206
|
-
lines.push(buf);
|
|
3207
|
-
const cmds = [];
|
|
3208
|
-
let section = "";
|
|
3209
|
-
for (const line of lines) {
|
|
3210
|
-
const t = line.trim();
|
|
3211
|
-
if (!t || t.startsWith("#"))
|
|
3212
|
-
continue;
|
|
3213
|
-
if (t.startsWith("/")) {
|
|
3214
|
-
section = t;
|
|
3215
|
-
continue;
|
|
3216
|
-
}
|
|
3217
|
-
cmds.push(section ? `${section} ${t}` : t);
|
|
3218
|
-
}
|
|
3219
|
-
return cmds;
|
|
3220
|
-
}
|
|
3221
|
-
|
|
3222
3350
|
// src/backups/create.ts
|
|
3223
3351
|
function labelSlug(label) {
|
|
3224
3352
|
const s = (label ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
@@ -6013,7 +6141,7 @@ var cache = null;
|
|
|
6013
6141
|
async function gateway() {
|
|
6014
6142
|
if (cache)
|
|
6015
6143
|
return cache;
|
|
6016
|
-
const { moduleCatalog } = await import("./cli-
|
|
6144
|
+
const { moduleCatalog } = await import("./cli-fpfchpjm.js");
|
|
6017
6145
|
const forIndex = [];
|
|
6018
6146
|
const byName = new Map;
|
|
6019
6147
|
for (const mod of moduleCatalog) {
|
|
@@ -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"));
|
|
@@ -1252,6 +1275,22 @@ async function uploadFileToDevice(deviceName, remotePath, data) {
|
|
|
1252
1275
|
ssh.disconnect();
|
|
1253
1276
|
}
|
|
1254
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
|
+
}
|
|
1255
1294
|
|
|
1256
1295
|
// src/core/registry.ts
|
|
1257
1296
|
import { z as z2 } from "zod";
|
|
@@ -1465,6 +1504,67 @@ function parseCertExpiry(detail, nowMs) {
|
|
|
1465
1504
|
}
|
|
1466
1505
|
return out;
|
|
1467
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
|
+
}
|
|
1468
1568
|
function parseFlagLegend(text) {
|
|
1469
1569
|
const out = {};
|
|
1470
1570
|
const line = text.split(`
|
|
@@ -2674,6 +2774,158 @@ ${rows.slice(0, 40).map((r) => ` \u2022 ${r.name ?? "?"} (${r.type ?? "?"})${r.
|
|
|
2674
2774
|
// src/tools/backup.ts
|
|
2675
2775
|
import { z as z5 } from "zod";
|
|
2676
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
|
+
|
|
2677
2929
|
// src/core/datestamp.ts
|
|
2678
2930
|
var MONTHS2 = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
2679
2931
|
function pad(n) {
|
|
@@ -2746,6 +2998,12 @@ async function deviceDateStamp(ctx) {
|
|
|
2746
2998
|
return hostStamp(new Date);
|
|
2747
2999
|
}
|
|
2748
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
|
+
|
|
2749
3007
|
// src/tools/backup.ts
|
|
2750
3008
|
var backupTools = [
|
|
2751
3009
|
defineTool({
|
|
@@ -2762,6 +3020,7 @@ var backupTools = [
|
|
|
2762
3020
|
async handler(a, ctx) {
|
|
2763
3021
|
const name = a.name || `backup_${await deviceDateStamp(ctx)}`;
|
|
2764
3022
|
ctx.info(`Creating backup: name=${name}`);
|
|
3023
|
+
const disk = await checkDiskSpace(ctx);
|
|
2765
3024
|
const cmd = new Cmd("/system backup save").set("name", name);
|
|
2766
3025
|
if (a.dont_encrypt)
|
|
2767
3026
|
cmd.raw("dont-encrypt=yes");
|
|
@@ -2770,13 +3029,35 @@ var backupTools = [
|
|
|
2770
3029
|
if (!a.include_password)
|
|
2771
3030
|
cmd.raw("password-file=no");
|
|
2772
3031
|
const result = await executeMikrotikCommand(cmd.build(), ctx);
|
|
2773
|
-
if (result.includes("saved") || result.trim() === "") {
|
|
2774
|
-
|
|
2775
|
-
|
|
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}
|
|
2776
3035
|
|
|
2777
|
-
|
|
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
|
+
}
|
|
2778
3056
|
}
|
|
2779
|
-
|
|
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.`;
|
|
2780
3061
|
}
|
|
2781
3062
|
}),
|
|
2782
3063
|
defineTool({
|
|
@@ -2825,6 +3106,21 @@ ${result}`;
|
|
|
2825
3106
|
async handler(a, ctx) {
|
|
2826
3107
|
const name = a.name || `export_${await deviceDateStamp(ctx)}`;
|
|
2827
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
|
+
}
|
|
2828
3124
|
const extension = a.file_format === "json" || a.file_format === "xml" ? a.file_format : "rsc";
|
|
2829
3125
|
const fullName = `${name}.${extension}`;
|
|
2830
3126
|
const cmd = new Cmd("/export");
|
|
@@ -2863,6 +3159,20 @@ ${fileDetails}` : `Export '${fullName}' created successfully.`;
|
|
|
2863
3159
|
name = `export_${cleanSection}_${await deviceDateStamp(ctx)}`;
|
|
2864
3160
|
}
|
|
2865
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
|
+
}
|
|
2866
3176
|
const cmd = new Cmd(`/${a.section} export`).set("file", name);
|
|
2867
3177
|
cmd.raw(!a.hide_sensitive ? "show-sensitive" : null);
|
|
2868
3178
|
cmd.raw(a.compact ? "compact" : null);
|
|
@@ -3013,127 +3323,6 @@ ${result}`;
|
|
|
3013
3323
|
// src/tools/local-backup.ts
|
|
3014
3324
|
import { z as z6 } from "zod";
|
|
3015
3325
|
|
|
3016
|
-
// src/core/slug.ts
|
|
3017
|
-
function deviceSlug(name) {
|
|
3018
|
-
const s = (name ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3019
|
-
return s || "device";
|
|
3020
|
-
}
|
|
3021
|
-
|
|
3022
|
-
// src/backups/vault.ts
|
|
3023
|
-
import {
|
|
3024
|
-
existsSync as existsSync2,
|
|
3025
|
-
mkdirSync,
|
|
3026
|
-
readFileSync as readFileSync4,
|
|
3027
|
-
readdirSync,
|
|
3028
|
-
renameSync,
|
|
3029
|
-
rmSync,
|
|
3030
|
-
statSync,
|
|
3031
|
-
writeFileSync
|
|
3032
|
-
} from "fs";
|
|
3033
|
-
import { basename, join as join4 } from "path";
|
|
3034
|
-
function backupDir() {
|
|
3035
|
-
if (process.env.MIKROTIK_BACKUP_DIR)
|
|
3036
|
-
return process.env.MIKROTIK_BACKUP_DIR;
|
|
3037
|
-
try {
|
|
3038
|
-
const dir = getConfig().backupDir;
|
|
3039
|
-
if (dir)
|
|
3040
|
-
return dir;
|
|
3041
|
-
} catch {}
|
|
3042
|
-
return DEFAULT_BACKUP_DIR;
|
|
3043
|
-
}
|
|
3044
|
-
function safeName(name) {
|
|
3045
|
-
const base = basename(name);
|
|
3046
|
-
if (base !== name || base === "." || base === ".." || !/^[A-Za-z0-9._-]+$/.test(base)) {
|
|
3047
|
-
throw new Error(`invalid backup name: ${name}`);
|
|
3048
|
-
}
|
|
3049
|
-
return base;
|
|
3050
|
-
}
|
|
3051
|
-
function listBackups() {
|
|
3052
|
-
const dir = backupDir();
|
|
3053
|
-
if (!existsSync2(dir))
|
|
3054
|
-
return [];
|
|
3055
|
-
return readdirSync(dir).filter((f) => f.endsWith(".rsc")).map((f) => {
|
|
3056
|
-
const st = statSync(join4(dir, f));
|
|
3057
|
-
const us = f.indexOf("_");
|
|
3058
|
-
return {
|
|
3059
|
-
name: f,
|
|
3060
|
-
bytes: st.size,
|
|
3061
|
-
modified: st.mtimeMs,
|
|
3062
|
-
device: us > 0 ? f.slice(0, us) : undefined
|
|
3063
|
-
};
|
|
3064
|
-
}).sort((a, b) => b.modified - a.modified);
|
|
3065
|
-
}
|
|
3066
|
-
function readBackup(name) {
|
|
3067
|
-
return readFileSync4(join4(backupDir(), safeName(name)), "utf8");
|
|
3068
|
-
}
|
|
3069
|
-
function writeBackup(name, content) {
|
|
3070
|
-
const dir = backupDir();
|
|
3071
|
-
mkdirSync(dir, { recursive: true });
|
|
3072
|
-
let final = safeName(name);
|
|
3073
|
-
if (existsSync2(join4(dir, final))) {
|
|
3074
|
-
const dot = final.lastIndexOf(".");
|
|
3075
|
-
const stem = dot > 0 ? final.slice(0, dot) : final;
|
|
3076
|
-
const ext = dot > 0 ? final.slice(dot) : "";
|
|
3077
|
-
let n = 2;
|
|
3078
|
-
while (existsSync2(join4(dir, `${stem}_${n}${ext}`)))
|
|
3079
|
-
n++;
|
|
3080
|
-
final = `${stem}_${n}${ext}`;
|
|
3081
|
-
}
|
|
3082
|
-
writeFileSync(join4(dir, final), content, "utf8");
|
|
3083
|
-
return final;
|
|
3084
|
-
}
|
|
3085
|
-
function deleteBackup(name) {
|
|
3086
|
-
const p = join4(backupDir(), safeName(name));
|
|
3087
|
-
if (!existsSync2(p))
|
|
3088
|
-
return false;
|
|
3089
|
-
rmSync(p);
|
|
3090
|
-
return true;
|
|
3091
|
-
}
|
|
3092
|
-
function renameBackup(oldName, newName) {
|
|
3093
|
-
const dir = backupDir();
|
|
3094
|
-
const from = join4(dir, safeName(oldName));
|
|
3095
|
-
let to = safeName(newName);
|
|
3096
|
-
if (!to.endsWith(".rsc"))
|
|
3097
|
-
to += ".rsc";
|
|
3098
|
-
const toPath = join4(dir, to);
|
|
3099
|
-
if (!existsSync2(from))
|
|
3100
|
-
throw new Error(`backup not found: ${oldName}`);
|
|
3101
|
-
if (existsSync2(toPath))
|
|
3102
|
-
throw new Error(`a backup named '${to}' already exists`);
|
|
3103
|
-
renameSync(from, toPath);
|
|
3104
|
-
return to;
|
|
3105
|
-
}
|
|
3106
|
-
function exportToCommands(text) {
|
|
3107
|
-
const lines = [];
|
|
3108
|
-
let buf = "";
|
|
3109
|
-
for (const raw of text.split(`
|
|
3110
|
-
`)) {
|
|
3111
|
-
const line = raw.replace(/\r$/, "");
|
|
3112
|
-
buf = buf ? `${buf} ${line.trim()}` : line;
|
|
3113
|
-
if (buf.trimEnd().endsWith("\\")) {
|
|
3114
|
-
buf = buf.trimEnd().slice(0, -1).trimEnd();
|
|
3115
|
-
continue;
|
|
3116
|
-
}
|
|
3117
|
-
lines.push(buf);
|
|
3118
|
-
buf = "";
|
|
3119
|
-
}
|
|
3120
|
-
if (buf)
|
|
3121
|
-
lines.push(buf);
|
|
3122
|
-
const cmds = [];
|
|
3123
|
-
let section = "";
|
|
3124
|
-
for (const line of lines) {
|
|
3125
|
-
const t = line.trim();
|
|
3126
|
-
if (!t || t.startsWith("#"))
|
|
3127
|
-
continue;
|
|
3128
|
-
if (t.startsWith("/")) {
|
|
3129
|
-
section = t;
|
|
3130
|
-
continue;
|
|
3131
|
-
}
|
|
3132
|
-
cmds.push(section ? `${section} ${t}` : t);
|
|
3133
|
-
}
|
|
3134
|
-
return cmds;
|
|
3135
|
-
}
|
|
3136
|
-
|
|
3137
3326
|
// src/backups/create.ts
|
|
3138
3327
|
function labelSlug(label) {
|
|
3139
3328
|
const s = (label ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
@@ -5928,7 +6117,7 @@ var cache = null;
|
|
|
5928
6117
|
async function gateway() {
|
|
5929
6118
|
if (cache)
|
|
5930
6119
|
return cache;
|
|
5931
|
-
const { moduleCatalog } = await import("./library-
|
|
6120
|
+
const { moduleCatalog } = await import("./library-r6dx5hw2.js");
|
|
5932
6121
|
const forIndex = [];
|
|
5933
6122
|
const byName = new Map;
|
|
5934
6123
|
for (const mod of moduleCatalog) {
|
package/package.json
CHANGED