@usex/mikrotik-mcp 4.13.0 → 4.15.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 +237 -130
- package/dist/index.js +1 -1
- package/dist/shared/{cli-p6yc41tg.js → cli-9s02dq5d.js} +1 -1
- package/dist/shared/{cli-07emrpkr.js → cli-w6ecrhxa.js} +1161 -1041
- package/dist/shared/{library-pczgrhdt.js → library-212xbkwr.js} +1037 -956
- package/dist/shared/{library-txj981yv.js → library-c0fxxq85.js} +1 -1
- package/dist/ui/observability.html +60 -60
- package/package.json +1 -1
- package/prompts/harden-against-bruteforce.md +140 -0
- package/schemas/tool-catalog.json +7 -9
- package/schemas/tools/create_filter_rule.json +4 -6
|
@@ -7510,97 +7510,595 @@ ${created.join(`
|
|
|
7510
7510
|
// src/tools/container.ts
|
|
7511
7511
|
import { z as z17 } from "zod";
|
|
7512
7512
|
|
|
7513
|
-
// src/
|
|
7514
|
-
|
|
7515
|
-
|
|
7513
|
+
// src/core/firewall-audit.ts
|
|
7514
|
+
import * as ipaddr from "ipaddr.js";
|
|
7515
|
+
var NON_MATCH = new Set([
|
|
7516
|
+
"#",
|
|
7517
|
+
"flags",
|
|
7518
|
+
"comment",
|
|
7519
|
+
"action",
|
|
7520
|
+
"chain",
|
|
7521
|
+
"bytes",
|
|
7522
|
+
"packets",
|
|
7523
|
+
"log",
|
|
7524
|
+
"log-prefix",
|
|
7525
|
+
"jump-target",
|
|
7526
|
+
".id",
|
|
7527
|
+
".nextid",
|
|
7528
|
+
"disabled",
|
|
7529
|
+
"dynamic",
|
|
7530
|
+
"invalid"
|
|
7531
|
+
]);
|
|
7532
|
+
var TRANSFORM_KEYS = new Set([
|
|
7533
|
+
"to-addresses",
|
|
7534
|
+
"to-ports",
|
|
7535
|
+
"to-address",
|
|
7536
|
+
"to-port",
|
|
7537
|
+
"address-list",
|
|
7538
|
+
"new-connection-mark",
|
|
7539
|
+
"new-packet-mark",
|
|
7540
|
+
"new-routing-mark",
|
|
7541
|
+
"new-dscp",
|
|
7542
|
+
"new-mss",
|
|
7543
|
+
"new-priority",
|
|
7544
|
+
"new-ttl",
|
|
7545
|
+
"jump-target"
|
|
7546
|
+
]);
|
|
7547
|
+
var NONDETERMINISTIC = new Set([
|
|
7548
|
+
"limit",
|
|
7549
|
+
"dst-limit",
|
|
7550
|
+
"random",
|
|
7551
|
+
"nth",
|
|
7552
|
+
"psd",
|
|
7553
|
+
"connection-bytes",
|
|
7554
|
+
"connection-rate",
|
|
7555
|
+
"rate",
|
|
7556
|
+
"time",
|
|
7557
|
+
"content",
|
|
7558
|
+
"layer7-protocol",
|
|
7559
|
+
"tls-host"
|
|
7560
|
+
]);
|
|
7561
|
+
var TERMINAL = new Set(["accept", "drop", "reject", "tarpit"]);
|
|
7562
|
+
var ADDRESS_KEYS = new Set(["src-address", "dst-address"]);
|
|
7563
|
+
var CATCH_ALL_ADDR = new Set(["0.0.0.0/0", "::/0"]);
|
|
7564
|
+
function rulesFromRows(rows) {
|
|
7565
|
+
return rows.map((r, i) => {
|
|
7566
|
+
const flags = r.flags ?? "";
|
|
7567
|
+
const match = {};
|
|
7568
|
+
const transform = {};
|
|
7569
|
+
for (const [k, v] of Object.entries(r)) {
|
|
7570
|
+
if (!v || NON_MATCH.has(k))
|
|
7571
|
+
continue;
|
|
7572
|
+
if (TRANSFORM_KEYS.has(k))
|
|
7573
|
+
transform[k] = v;
|
|
7574
|
+
else
|
|
7575
|
+
match[k] = v;
|
|
7576
|
+
}
|
|
7577
|
+
const num = (s) => {
|
|
7578
|
+
if (s == null)
|
|
7579
|
+
return;
|
|
7580
|
+
const n = Number(s.replace(/\s/g, ""));
|
|
7581
|
+
return Number.isFinite(n) ? n : undefined;
|
|
7582
|
+
};
|
|
7583
|
+
return {
|
|
7584
|
+
index: r["#"] != null && /^\d+$/.test(r["#"]) ? Number(r["#"]) : i,
|
|
7585
|
+
chain: r.chain ?? "?",
|
|
7586
|
+
action: r.action ?? "?",
|
|
7587
|
+
disabled: flags.includes("X"),
|
|
7588
|
+
dynamic: flags.includes("D"),
|
|
7589
|
+
comment: r.comment,
|
|
7590
|
+
packets: num(r.packets),
|
|
7591
|
+
bytes: num(r.bytes),
|
|
7592
|
+
match,
|
|
7593
|
+
transform,
|
|
7594
|
+
raw: r
|
|
7595
|
+
};
|
|
7596
|
+
});
|
|
7516
7597
|
}
|
|
7517
|
-
function
|
|
7518
|
-
|
|
7598
|
+
function toCidr(value) {
|
|
7599
|
+
try {
|
|
7600
|
+
if (value.includes("/"))
|
|
7601
|
+
return ipaddr.parseCIDR(value);
|
|
7602
|
+
const addr = ipaddr.parse(value);
|
|
7603
|
+
return [addr, addr.kind() === "ipv6" ? 128 : 32];
|
|
7604
|
+
} catch {
|
|
7605
|
+
return null;
|
|
7606
|
+
}
|
|
7519
7607
|
}
|
|
7520
|
-
function
|
|
7521
|
-
const
|
|
7522
|
-
|
|
7608
|
+
function cidrContains(a, b) {
|
|
7609
|
+
const A = toCidr(a);
|
|
7610
|
+
const B = toCidr(b);
|
|
7611
|
+
if (!A || !B)
|
|
7523
7612
|
return false;
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
if (
|
|
7527
|
-
return
|
|
7528
|
-
if (
|
|
7529
|
-
return
|
|
7530
|
-
|
|
7613
|
+
const [aAddr, aBits] = A;
|
|
7614
|
+
const [bAddr, bBits] = B;
|
|
7615
|
+
if (aAddr.kind() !== bAddr.kind())
|
|
7616
|
+
return false;
|
|
7617
|
+
if (aBits > bBits)
|
|
7618
|
+
return false;
|
|
7619
|
+
try {
|
|
7620
|
+
return bAddr.match(aAddr, aBits);
|
|
7621
|
+
} catch {
|
|
7622
|
+
return false;
|
|
7623
|
+
}
|
|
7624
|
+
}
|
|
7625
|
+
function covers(key, aVal, bVal) {
|
|
7626
|
+
if (aVal === bVal)
|
|
7531
7627
|
return true;
|
|
7628
|
+
if (ADDRESS_KEYS.has(key))
|
|
7629
|
+
return cidrContains(aVal, bVal);
|
|
7532
7630
|
return false;
|
|
7533
7631
|
}
|
|
7534
|
-
|
|
7535
|
-
|
|
7536
|
-
|
|
7632
|
+
var INTERFACE_LIST_PAIRS = [
|
|
7633
|
+
["in-interface-list", "in-interface"],
|
|
7634
|
+
["out-interface-list", "out-interface"]
|
|
7635
|
+
];
|
|
7636
|
+
var _ifaceLists;
|
|
7637
|
+
function interfaceListCovers(aKey, aVal, bKey, bVal) {
|
|
7638
|
+
if (!_ifaceLists)
|
|
7639
|
+
return;
|
|
7640
|
+
for (const [listKey, ifaceKey] of INTERFACE_LIST_PAIRS) {
|
|
7641
|
+
if (aKey === listKey && bKey === ifaceKey) {
|
|
7642
|
+
const negated = aVal.startsWith("!");
|
|
7643
|
+
const listName = negated ? aVal.slice(1) : aVal;
|
|
7644
|
+
const members = _ifaceLists.get(listName);
|
|
7645
|
+
if (!members)
|
|
7646
|
+
return;
|
|
7647
|
+
const isMember = members.has(bVal);
|
|
7648
|
+
return negated ? !isMember : isMember;
|
|
7649
|
+
}
|
|
7650
|
+
}
|
|
7651
|
+
return;
|
|
7537
7652
|
}
|
|
7538
|
-
|
|
7539
|
-
|
|
7540
|
-
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
|
|
7653
|
+
function aCoversB(a, b) {
|
|
7654
|
+
for (const [k, v] of Object.entries(a.match)) {
|
|
7655
|
+
const bv = b.match[k];
|
|
7656
|
+
if (bv !== undefined) {
|
|
7657
|
+
if (!covers(k, v, bv))
|
|
7658
|
+
return false;
|
|
7659
|
+
continue;
|
|
7660
|
+
}
|
|
7661
|
+
let crossCovered = false;
|
|
7662
|
+
for (const [bk, bval] of Object.entries(b.match)) {
|
|
7663
|
+
const result = interfaceListCovers(k, v, bk, bval);
|
|
7664
|
+
if (result === true) {
|
|
7665
|
+
crossCovered = true;
|
|
7666
|
+
break;
|
|
7667
|
+
}
|
|
7668
|
+
}
|
|
7669
|
+
if (!crossCovered)
|
|
7670
|
+
return false;
|
|
7671
|
+
}
|
|
7672
|
+
return true;
|
|
7544
7673
|
}
|
|
7545
|
-
|
|
7546
|
-
|
|
7547
|
-
|
|
7548
|
-
|
|
7549
|
-
|
|
7674
|
+
function matchesAll(rule) {
|
|
7675
|
+
for (const [k, v] of Object.entries(rule.match)) {
|
|
7676
|
+
if (ADDRESS_KEYS.has(k) && CATCH_ALL_ADDR.has(v))
|
|
7677
|
+
continue;
|
|
7678
|
+
return false;
|
|
7679
|
+
}
|
|
7680
|
+
return true;
|
|
7550
7681
|
}
|
|
7551
|
-
|
|
7552
|
-
|
|
7553
|
-
if (!n || n <= 0)
|
|
7554
|
-
return text;
|
|
7555
|
-
const lines = text.split(`
|
|
7556
|
-
`);
|
|
7557
|
-
return lines.length <= n ? text : lines.slice(-n).join(`
|
|
7558
|
-
`);
|
|
7682
|
+
function hasNondeterministic(rule) {
|
|
7683
|
+
return Object.keys(rule.match).some((k) => NONDETERMINISTIC.has(k));
|
|
7559
7684
|
}
|
|
7560
|
-
|
|
7561
|
-
|
|
7562
|
-
|
|
7563
|
-
|
|
7564
|
-
|
|
7565
|
-
|
|
7566
|
-
|
|
7567
|
-
|
|
7685
|
+
function matchSummary(rule) {
|
|
7686
|
+
const order = [
|
|
7687
|
+
"protocol",
|
|
7688
|
+
"src-address",
|
|
7689
|
+
"src-port",
|
|
7690
|
+
"dst-address",
|
|
7691
|
+
"dst-port",
|
|
7692
|
+
"in-interface",
|
|
7693
|
+
"out-interface",
|
|
7694
|
+
"in-interface-list",
|
|
7695
|
+
"out-interface-list",
|
|
7696
|
+
"connection-state",
|
|
7697
|
+
"src-address-list",
|
|
7698
|
+
"dst-address-list"
|
|
7699
|
+
];
|
|
7700
|
+
const parts = order.filter((k) => rule.match[k]).map((k) => `${k}=${rule.match[k]}`);
|
|
7701
|
+
return parts.length ? parts.join(" ") : "any";
|
|
7568
7702
|
}
|
|
7569
|
-
|
|
7570
|
-
|
|
7571
|
-
|
|
7572
|
-
}
|
|
7573
|
-
|
|
7574
|
-
|
|
7575
|
-
|
|
7576
|
-
|
|
7577
|
-
|
|
7578
|
-
|
|
7579
|
-
|
|
7580
|
-
|
|
7581
|
-
|
|
7582
|
-
|
|
7583
|
-
detail:
|
|
7584
|
-
|
|
7585
|
-
|
|
7586
|
-
|
|
7587
|
-
|
|
7588
|
-
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
7703
|
+
function disableAction(table, index) {
|
|
7704
|
+
const tool = table === "filter" ? "disable_filter_rule" : table === "nat" ? "disable_nat_rule" : undefined;
|
|
7705
|
+
return tool ? { tool, args: { rule_id: String(index) }, label: `Disable rule ${index}` } : undefined;
|
|
7706
|
+
}
|
|
7707
|
+
function auditFilter(rules) {
|
|
7708
|
+
const findings = [];
|
|
7709
|
+
const active2 = rules.filter((r) => !r.disabled && !r.dynamic);
|
|
7710
|
+
if (rules.length === 0) {
|
|
7711
|
+
findings.push({
|
|
7712
|
+
kind: "no-firewall",
|
|
7713
|
+
severity: "high",
|
|
7714
|
+
table: "filter",
|
|
7715
|
+
chain: "input",
|
|
7716
|
+
title: "No firewall configured",
|
|
7717
|
+
detail: "No firewall filter rules are configured. RouterOS's default policy is ACCEPT, " + "so the device currently allows all input and forwarded traffic.",
|
|
7718
|
+
suggestion: "Add a baseline ruleset: accept established/related, drop invalid, accept what you need, then drop everything else."
|
|
7719
|
+
});
|
|
7720
|
+
return findings;
|
|
7721
|
+
}
|
|
7722
|
+
const chains = [...new Set(active2.map((r) => r.chain))];
|
|
7723
|
+
for (const chain of chains) {
|
|
7724
|
+
const chainRules = active2.filter((r) => r.chain === chain);
|
|
7725
|
+
for (let j = 0;j < chainRules.length; j++) {
|
|
7726
|
+
const b = chainRules[j];
|
|
7727
|
+
if (b.action === "accept" && matchesAll(b)) {
|
|
7728
|
+
const sev = chain === "output" ? "low" : "high";
|
|
7729
|
+
findings.push({
|
|
7730
|
+
kind: "broad-accept",
|
|
7731
|
+
severity: sev,
|
|
7732
|
+
table: "filter",
|
|
7733
|
+
chain,
|
|
7734
|
+
ruleIndex: b.index,
|
|
7735
|
+
title: "Overly broad accept",
|
|
7736
|
+
detail: `Rule ${b.index} accepts ALL traffic in the ${chain} chain (no real match conditions), bypassing every rule after it.`,
|
|
7737
|
+
suggestion: `Scope rule ${b.index} to the specific source/port it should allow, or remove it.`,
|
|
7738
|
+
action: disableAction("filter", b.index)
|
|
7739
|
+
});
|
|
7740
|
+
}
|
|
7741
|
+
for (let i = 0;i < j; i++) {
|
|
7742
|
+
const a = chainRules[i];
|
|
7743
|
+
if (!TERMINAL.has(a.action) || hasNondeterministic(a))
|
|
7744
|
+
continue;
|
|
7745
|
+
if (aCoversB(a, b)) {
|
|
7746
|
+
findings.push({
|
|
7747
|
+
kind: "shadowed",
|
|
7748
|
+
severity: "medium",
|
|
7749
|
+
table: "filter",
|
|
7750
|
+
chain,
|
|
7751
|
+
ruleIndex: b.index,
|
|
7752
|
+
relatedIndex: a.index,
|
|
7753
|
+
title: "Unreachable rule",
|
|
7754
|
+
detail: `Rule ${b.index} (${b.action} ${matchSummary(b)}) can never match \u2014 rule ${a.index} ` + `already ${a.action}s ${matchesAll(a) ? "all traffic" : matchSummary(a)} earlier in the ${chain} chain.`,
|
|
7755
|
+
suggestion: `Remove rule ${b.index}, or move it above rule ${a.index} if it was meant to take effect first.`,
|
|
7756
|
+
action: disableAction("filter", b.index)
|
|
7757
|
+
});
|
|
7758
|
+
break;
|
|
7759
|
+
}
|
|
7760
|
+
}
|
|
7761
|
+
}
|
|
7762
|
+
if ((chain === "input" || chain === "forward") && chainRules.length > 0) {
|
|
7763
|
+
const hasDrop = chainRules.some((r) => (r.action === "drop" || r.action === "reject") && matchesAll(r));
|
|
7764
|
+
if (!hasDrop) {
|
|
7765
|
+
findings.push({
|
|
7766
|
+
kind: "missing-default-drop",
|
|
7767
|
+
severity: "high",
|
|
7768
|
+
table: "filter",
|
|
7769
|
+
chain,
|
|
7770
|
+
title: "No default-drop",
|
|
7771
|
+
detail: `The ${chain} chain has no catch-all drop, so anything not explicitly accepted is ACCEPTED (RouterOS default policy).`,
|
|
7772
|
+
suggestion: `Append a 'drop all' rule at the end of the ${chain} chain.`
|
|
7773
|
+
});
|
|
7774
|
+
}
|
|
7775
|
+
}
|
|
7776
|
+
}
|
|
7777
|
+
findings.push(...duplicateFindings(active2, "filter"));
|
|
7778
|
+
findings.push(...deadFindings(active2, "filter"));
|
|
7779
|
+
return findings;
|
|
7780
|
+
}
|
|
7781
|
+
function auditTransform(rules, table) {
|
|
7782
|
+
const active2 = rules.filter((r) => !r.disabled && !r.dynamic);
|
|
7783
|
+
return [...duplicateFindings(active2, table), ...deadFindings(active2, table)];
|
|
7784
|
+
}
|
|
7785
|
+
function ruleKey(r) {
|
|
7786
|
+
const m = Object.entries(r.match).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${k}=${v}`).join("&");
|
|
7787
|
+
const t = Object.entries(r.transform).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${k}=${v}`).join("&");
|
|
7788
|
+
return `${r.chain}|${r.action}|${m}|${t}`;
|
|
7789
|
+
}
|
|
7790
|
+
function duplicateFindings(active2, table) {
|
|
7791
|
+
const findings = [];
|
|
7792
|
+
const seen = new Map;
|
|
7793
|
+
for (const r of active2) {
|
|
7794
|
+
const key = ruleKey(r);
|
|
7795
|
+
const first = seen.get(key);
|
|
7796
|
+
if (first) {
|
|
7797
|
+
findings.push({
|
|
7798
|
+
kind: "duplicate",
|
|
7799
|
+
severity: "medium",
|
|
7800
|
+
table,
|
|
7801
|
+
chain: r.chain,
|
|
7802
|
+
ruleIndex: r.index,
|
|
7803
|
+
relatedIndex: first.index,
|
|
7804
|
+
title: "Duplicate rule",
|
|
7805
|
+
detail: `Rule ${r.index} in the ${r.chain} chain is identical to rule ${first.index} (same match and action) \u2014 it is redundant.`,
|
|
7806
|
+
suggestion: `Remove the duplicate rule ${r.index}.`,
|
|
7807
|
+
action: disableAction(table, r.index)
|
|
7808
|
+
});
|
|
7809
|
+
} else {
|
|
7810
|
+
seen.set(key, r);
|
|
7811
|
+
}
|
|
7812
|
+
}
|
|
7813
|
+
return findings;
|
|
7814
|
+
}
|
|
7815
|
+
function deadFindings(active2, table) {
|
|
7816
|
+
const findings = [];
|
|
7817
|
+
for (const r of active2) {
|
|
7818
|
+
if (r.packets === 0) {
|
|
7819
|
+
findings.push({
|
|
7820
|
+
kind: "dead-rule",
|
|
7821
|
+
severity: "low",
|
|
7822
|
+
table,
|
|
7823
|
+
chain: r.chain,
|
|
7824
|
+
ruleIndex: r.index,
|
|
7825
|
+
title: "No hits since boot",
|
|
7826
|
+
detail: `Rule ${r.index} (${r.chain} ${r.action}) has matched 0 packets since the counters last reset \u2014 it may be unused (or the device rebooted recently).`,
|
|
7827
|
+
suggestion: `Confirm rule ${r.index} is still needed; remove it if obsolete.`
|
|
7828
|
+
});
|
|
7829
|
+
}
|
|
7830
|
+
}
|
|
7831
|
+
return findings;
|
|
7832
|
+
}
|
|
7833
|
+
var WEIGHT2 = { high: 20, medium: 8, low: 2 };
|
|
7834
|
+
function grade2(score) {
|
|
7835
|
+
if (score === 0)
|
|
7836
|
+
return "clean";
|
|
7837
|
+
if (score < 15)
|
|
7838
|
+
return "good";
|
|
7839
|
+
if (score < 40)
|
|
7840
|
+
return "fair";
|
|
7841
|
+
if (score < 75)
|
|
7842
|
+
return "poor";
|
|
7843
|
+
return "critical";
|
|
7844
|
+
}
|
|
7845
|
+
function auditFirewall(input) {
|
|
7846
|
+
_ifaceLists = input.interfaceLists;
|
|
7847
|
+
const findings = [];
|
|
7848
|
+
if (input.filter)
|
|
7849
|
+
findings.push(...auditFilter(input.filter));
|
|
7850
|
+
if (input.nat)
|
|
7851
|
+
findings.push(...auditTransform(input.nat, "nat"));
|
|
7852
|
+
if (input.mangle)
|
|
7853
|
+
findings.push(...auditTransform(input.mangle, "mangle"));
|
|
7854
|
+
const sevRank = { high: 0, medium: 1, low: 2 };
|
|
7855
|
+
findings.sort((a, b) => sevRank[a.severity] - sevRank[b.severity] || a.table.localeCompare(b.table) || (a.ruleIndex ?? -1) - (b.ruleIndex ?? -1));
|
|
7856
|
+
const counts = { high: 0, medium: 0, low: 0, total: findings.length };
|
|
7857
|
+
let raw = 0;
|
|
7858
|
+
for (const f of findings) {
|
|
7859
|
+
counts[f.severity]++;
|
|
7860
|
+
raw += WEIGHT2[f.severity];
|
|
7861
|
+
}
|
|
7862
|
+
const riskScore = Math.min(100, raw);
|
|
7863
|
+
const ruleCount = (input.filter?.length ?? 0) + (input.nat?.length ?? 0) + (input.mangle?.length ?? 0);
|
|
7864
|
+
return { riskScore, grade: grade2(riskScore), counts, ruleCount, findings };
|
|
7865
|
+
}
|
|
7866
|
+
function renderReport(report, device) {
|
|
7867
|
+
const head = `FIREWALL AUDIT \u2014 ${device}
|
|
7868
|
+
|
|
7869
|
+
` + `Risk score: ${report.riskScore}/100 (${report.grade})
|
|
7870
|
+
` + `${report.ruleCount} rule(s) analysed \xB7 ${report.counts.high} high, ${report.counts.medium} medium, ${report.counts.low} low
|
|
7871
|
+
`;
|
|
7872
|
+
if (report.findings.length === 0) {
|
|
7873
|
+
return `${head}
|
|
7874
|
+
No issues found \u2014 the ruleset looks clean. \u2713`;
|
|
7875
|
+
}
|
|
7876
|
+
const body = report.findings.map((f, i) => {
|
|
7877
|
+
const tag = f.severity.toUpperCase().padEnd(6);
|
|
7878
|
+
return `${i + 1}. [${tag}] ${f.title} (${f.table}/${f.chain})
|
|
7879
|
+
${f.detail}
|
|
7880
|
+
\u2192 ${f.suggestion}`;
|
|
7881
|
+
}).join(`
|
|
7882
|
+
|
|
7883
|
+
`);
|
|
7884
|
+
return `${head}
|
|
7885
|
+
${body}`;
|
|
7886
|
+
}
|
|
7887
|
+
|
|
7888
|
+
// src/utils/firewall-query.ts
|
|
7889
|
+
async function fetchFilterChainRules(chain, ctx) {
|
|
7890
|
+
const rows = await fetchRows(`/ip firewall filter print detail where chain=${chain}`, ctx);
|
|
7891
|
+
return rulesFromRows(rows);
|
|
7892
|
+
}
|
|
7893
|
+
async function addressListCount(list, ctx) {
|
|
7894
|
+
const raw = await executeMikrotikCommand(`/ip firewall address-list print count-only where list=${JSON.stringify(list)}`, ctx);
|
|
7895
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
7896
|
+
return Number.isFinite(n) ? n : 0;
|
|
7897
|
+
}
|
|
7898
|
+
async function filterChainRuleIds(chain, ctx) {
|
|
7899
|
+
const raw = await executeMikrotikCommand(`:foreach i in=[/ip firewall filter find chain=${chain}] do={:put $i}`, ctx);
|
|
7900
|
+
if (isEmpty(raw))
|
|
7901
|
+
return [];
|
|
7902
|
+
return raw.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => /^\*[0-9A-Fa-f]+$/.test(l));
|
|
7903
|
+
}
|
|
7904
|
+
// src/utils/ip.ts
|
|
7905
|
+
function isIpAddress(s) {
|
|
7906
|
+
return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(s);
|
|
7907
|
+
}
|
|
7908
|
+
function isIpLike(s) {
|
|
7909
|
+
return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d+)?$/.test(s);
|
|
7910
|
+
}
|
|
7911
|
+
function isPrivateIp(ip) {
|
|
7912
|
+
const p = ip.split(".").map(Number);
|
|
7913
|
+
if (p.length !== 4)
|
|
7914
|
+
return false;
|
|
7915
|
+
if (p[0] === 10)
|
|
7916
|
+
return true;
|
|
7917
|
+
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31)
|
|
7918
|
+
return true;
|
|
7919
|
+
if (p[0] === 192 && p[1] === 168)
|
|
7920
|
+
return true;
|
|
7921
|
+
if (p[0] === 169 && p[1] === 254)
|
|
7922
|
+
return true;
|
|
7923
|
+
return false;
|
|
7924
|
+
}
|
|
7925
|
+
// src/utils/num.ts
|
|
7926
|
+
function num(s) {
|
|
7927
|
+
return Number.parseInt(s ?? "0", 10) || 0;
|
|
7928
|
+
}
|
|
7929
|
+
// src/utils/or-match.ts
|
|
7930
|
+
function orMatch(field, values, op = "~") {
|
|
7931
|
+
if (values.length === 0)
|
|
7932
|
+
return "";
|
|
7933
|
+
const terms = values.map((v) => `${field}${op}"${v.replace(/"/g, "\\\"")}"`);
|
|
7934
|
+
return `(${terms.join(" or ")})`;
|
|
7935
|
+
}
|
|
7936
|
+
// src/utils/redact-secrets.ts
|
|
7937
|
+
var SENSITIVE_KEYS = ["password", "shared-secret", "secret"];
|
|
7938
|
+
var REDACT_RE = new RegExp(`(?<![\\w-])(${SENSITIVE_KEYS.join("|")})="[^"]*"`, "g");
|
|
7939
|
+
function redactSecrets(text) {
|
|
7940
|
+
return text.replace(REDACT_RE, '$1="***"');
|
|
7941
|
+
}
|
|
7942
|
+
// src/utils/safe-mode-apply.ts
|
|
7943
|
+
async function applyCommandsDirect(ctx, commands) {
|
|
7944
|
+
let applied = 0;
|
|
7945
|
+
for (const cmd of commands) {
|
|
7946
|
+
const out = await executeMikrotikCommand(cmd, ctx).catch((e) => `error: ${String(e)}`);
|
|
7947
|
+
if (looksLikeError(out) || out.startsWith("error:")) {
|
|
7948
|
+
return { applied, error: out.trim().split(`
|
|
7949
|
+
`)[0] };
|
|
7950
|
+
}
|
|
7951
|
+
applied++;
|
|
7952
|
+
}
|
|
7953
|
+
return { applied };
|
|
7954
|
+
}
|
|
7955
|
+
async function applyWritesSafely(ctx, deviceName, commands, opts = {}) {
|
|
7956
|
+
const total = commands.length;
|
|
7957
|
+
const fallback = opts.allowDirectFallback === true;
|
|
7958
|
+
if (total === 0)
|
|
7959
|
+
return {
|
|
7960
|
+
applied: 0,
|
|
7961
|
+
total,
|
|
7962
|
+
safeMode: "not used (nothing to write)",
|
|
7963
|
+
committed: true,
|
|
7964
|
+
fellBack: false
|
|
7965
|
+
};
|
|
7966
|
+
if (getDevice(deviceName).mac) {
|
|
7967
|
+
if (!fallback)
|
|
7968
|
+
return {
|
|
7969
|
+
applied: 0,
|
|
7970
|
+
total,
|
|
7971
|
+
safeMode: "unavailable (MAC-Telnet device has no Safe Mode)",
|
|
7972
|
+
committed: false,
|
|
7973
|
+
fellBack: false
|
|
7974
|
+
};
|
|
7975
|
+
const r = await applyCommandsDirect(ctx, commands);
|
|
7976
|
+
return {
|
|
7977
|
+
applied: r.applied,
|
|
7978
|
+
total,
|
|
7979
|
+
safeMode: "not used (MAC-Telnet device \u2014 no Safe Mode)",
|
|
7980
|
+
committed: !r.error,
|
|
7981
|
+
error: r.error,
|
|
7982
|
+
fellBack: false
|
|
7983
|
+
};
|
|
7984
|
+
}
|
|
7985
|
+
const mgr = getSafeModeManager(deviceName);
|
|
7986
|
+
const en = await mgr.enable();
|
|
7987
|
+
if (en.startsWith("Error")) {
|
|
7988
|
+
if (!fallback)
|
|
7989
|
+
return {
|
|
7990
|
+
applied: 0,
|
|
7991
|
+
total,
|
|
7992
|
+
safeMode: `failed to enable: ${en}`,
|
|
7993
|
+
committed: false,
|
|
7994
|
+
fellBack: false
|
|
7995
|
+
};
|
|
7996
|
+
const r = await applyCommandsDirect(ctx, commands);
|
|
7997
|
+
return {
|
|
7998
|
+
applied: r.applied,
|
|
7999
|
+
total,
|
|
8000
|
+
safeMode: `unavailable (${en.replace(/^Error:?\s*/, "")}) \u2014 applied directly; snapshot is the rollback point`,
|
|
8001
|
+
committed: !r.error,
|
|
8002
|
+
error: r.error,
|
|
8003
|
+
fellBack: true
|
|
8004
|
+
};
|
|
8005
|
+
}
|
|
8006
|
+
let applied = 0;
|
|
8007
|
+
let wedged;
|
|
8008
|
+
for (const cmd of commands) {
|
|
8009
|
+
const out = await mgr.execute(cmd).catch((e) => `error: ${String(e)}`);
|
|
8010
|
+
if (looksLikeError(out) || out.startsWith("error:")) {
|
|
8011
|
+
wedged = out.trim().split(`
|
|
8012
|
+
`)[0];
|
|
8013
|
+
break;
|
|
8014
|
+
}
|
|
8015
|
+
applied++;
|
|
8016
|
+
}
|
|
8017
|
+
if (wedged !== undefined) {
|
|
8018
|
+
await mgr.rollback().catch(() => {
|
|
8019
|
+
return;
|
|
8020
|
+
});
|
|
8021
|
+
if (!fallback)
|
|
8022
|
+
return {
|
|
8023
|
+
applied,
|
|
8024
|
+
total,
|
|
8025
|
+
safeMode: `rolled back (a write failed: ${wedged})`,
|
|
8026
|
+
committed: false,
|
|
8027
|
+
error: wedged,
|
|
8028
|
+
fellBack: false
|
|
8029
|
+
};
|
|
8030
|
+
const r = await applyCommandsDirect(ctx, commands);
|
|
8031
|
+
return {
|
|
8032
|
+
applied: r.applied,
|
|
8033
|
+
total,
|
|
8034
|
+
safeMode: `Safe Mode wedged (${wedged}) \u2014 rolled back and re-applied directly (snapshot is the rollback point)`,
|
|
8035
|
+
committed: !r.error,
|
|
8036
|
+
error: r.error,
|
|
8037
|
+
fellBack: true
|
|
8038
|
+
};
|
|
8039
|
+
}
|
|
8040
|
+
const c = await mgr.commit();
|
|
8041
|
+
return {
|
|
8042
|
+
applied,
|
|
8043
|
+
total,
|
|
8044
|
+
safeMode: c.ok ? "committed" : `commit unclear (${c.message}) \u2014 re-run to reconcile if the operation is idempotent`,
|
|
8045
|
+
committed: c.ok,
|
|
8046
|
+
fellBack: false
|
|
8047
|
+
};
|
|
8048
|
+
}
|
|
8049
|
+
// src/utils/tail-lines.ts
|
|
8050
|
+
function tailLines(text, n) {
|
|
8051
|
+
if (!n || n <= 0)
|
|
8052
|
+
return text;
|
|
8053
|
+
const lines = text.split(`
|
|
8054
|
+
`);
|
|
8055
|
+
return lines.length <= n ? text : lines.slice(-n).join(`
|
|
8056
|
+
`);
|
|
8057
|
+
}
|
|
8058
|
+
// src/tools/container.ts
|
|
8059
|
+
var NOT_AVAILABLE = "Container support is not available on this device. Install the `container` package and enable " + "device-mode (`/system/device-mode/update container=yes`, then physically confirm).";
|
|
8060
|
+
function containerMatch(name, tag) {
|
|
8061
|
+
if (name)
|
|
8062
|
+
return `name=${quoteValue(name)}`;
|
|
8063
|
+
if (tag)
|
|
8064
|
+
return `tag~${quoteValue(tag)}`;
|
|
8065
|
+
return null;
|
|
8066
|
+
}
|
|
8067
|
+
var IDENTITY = {
|
|
8068
|
+
name: z17.string().optional().describe("Container name (set via add_container)"),
|
|
8069
|
+
tag: z17.string().optional().describe("Image tag to match (e.g. 'pihole') if no name")
|
|
8070
|
+
};
|
|
8071
|
+
var containerTools = [
|
|
8072
|
+
defineTool({
|
|
8073
|
+
name: "list_containers",
|
|
8074
|
+
title: "List Containers",
|
|
8075
|
+
annotations: READ,
|
|
8076
|
+
description: "List every OCI container on the device (`/container print`) with its status, image tag, name, VETH " + "interface and root-dir \u2014 the starting point for any container work and the way to POLL the lifecycle, " + "which is asynchronous: status moves extracting \u2192 stopped (ready to start) \u2192 running. Use this to find " + "the `name`/`tag` the other tools take, to confirm an add has finished extracting before start_container, " + "and to confirm a stop has completed before remove_container. Filter by partial `name_filter`, " + "`tag_filter`, or `status_filter` (e.g. 'running', 'stopped'); set `detail=true` for the full property " + "block. For one container use get_container. A container's stdout/stderr is in the system log " + '(`/log print where topics~"container"`) when it was created with logging=yes.',
|
|
8077
|
+
inputSchema: {
|
|
8078
|
+
name_filter: z17.string().optional().describe("Partial container name match"),
|
|
8079
|
+
tag_filter: z17.string().optional().describe("Partial image tag match"),
|
|
8080
|
+
status_filter: z17.string().optional().describe("e.g. 'running', 'stopped'"),
|
|
8081
|
+
detail: z17.boolean().default(false).describe("Show the full per-container property block")
|
|
8082
|
+
},
|
|
8083
|
+
async handler(a, ctx) {
|
|
8084
|
+
ctx.info("Listing containers");
|
|
8085
|
+
const filters = [];
|
|
8086
|
+
if (a.name_filter)
|
|
8087
|
+
filters.push(`name~"${a.name_filter}"`);
|
|
8088
|
+
if (a.tag_filter)
|
|
8089
|
+
filters.push(`tag~"${a.tag_filter}"`);
|
|
8090
|
+
if (a.status_filter)
|
|
8091
|
+
filters.push(`status~"${a.status_filter}"`);
|
|
8092
|
+
const result = await executeMikrotikCommand(`/container print${a.detail ? " detail" : ""}${whereClause(filters)}`, ctx);
|
|
8093
|
+
if (commandUnsupported(result))
|
|
8094
|
+
return NOT_AVAILABLE;
|
|
8095
|
+
return isEmpty(result) ? "No containers found matching the criteria." : `CONTAINERS:
|
|
8096
|
+
|
|
8097
|
+
${redactSecrets(result)}`;
|
|
8098
|
+
}
|
|
8099
|
+
}),
|
|
8100
|
+
defineTool({
|
|
8101
|
+
name: "get_container",
|
|
7604
8102
|
title: "Get Container Detail",
|
|
7605
8103
|
annotations: READ,
|
|
7606
8104
|
description: "Full detail for one container (`/container print detail`) \u2014 status, image tag, interface, root-dir, " + "env/mounts, cmd/entrypoint, hostname/dns, logging and start-on-boot. Identify by `name` (preferred) or " + "`tag`. Use list_containers to discover identifiers.",
|
|
@@ -8306,7 +8804,7 @@ var cache = null;
|
|
|
8306
8804
|
async function gateway() {
|
|
8307
8805
|
if (cache)
|
|
8308
8806
|
return cache;
|
|
8309
|
-
const { moduleCatalog } = await import("./library-
|
|
8807
|
+
const { moduleCatalog } = await import("./library-c0fxxq85.js");
|
|
8310
8808
|
const forIndex = [];
|
|
8311
8809
|
const byName = new Map;
|
|
8312
8810
|
for (const mod of moduleCatalog) {
|
|
@@ -8722,872 +9220,495 @@ ${result}`;
|
|
|
8722
9220
|
title: "Disable DNS Static Record",
|
|
8723
9221
|
annotations: WRITE_IDEMPOTENT,
|
|
8724
9222
|
description: "Deactivates a static DNS record (`/ip dns static set disabled=yes`) without deleting it \u2014 " + "the entry is retained but ignored during resolution. " + "The `entry_id` is the `.id` from list_dns_static. " + "To re-activate use enable_dns_static. " + "To permanently delete use remove_dns_static. " + "Returns the updated entry's full detail.",
|
|
8725
|
-
inputSchema: { entry_id: z21.string() },
|
|
8726
|
-
handler: (a, ctx) => updateDnsStatic({ entry_id: a.entry_id, disabled: true }, ctx)
|
|
8727
|
-
}),
|
|
8728
|
-
defineTool({
|
|
8729
|
-
name: "get_dns_cache",
|
|
8730
|
-
title: "Get DNS Cache Entries",
|
|
8731
|
-
annotations: READ,
|
|
8732
|
-
description: "Lists all hostnames currently held in the router's DNS resolver cache (`/ip dns cache print`). " + "Use to inspect what the router has already resolved and what is available without re-querying " + "upstream servers. " + "For summary statistics (cache-size, cache-used, cache-max-ttl, total entry count) use " + "get_dns_cache_statistics. " + "To clear the cache use flush_dns_cache. " + "Returns the full cache table, or a message if the cache is empty.",
|
|
8733
|
-
async handler(_a, ctx) {
|
|
8734
|
-
ctx.info("Getting DNS cache");
|
|
8735
|
-
const result = await executeMikrotikCommand("/ip dns cache print", ctx);
|
|
8736
|
-
return isEmpty(result) ? "DNS cache is empty." : `DNS CACHE:
|
|
8737
|
-
|
|
8738
|
-
${result}`;
|
|
8739
|
-
}
|
|
8740
|
-
}),
|
|
8741
|
-
defineTool({
|
|
8742
|
-
name: "flush_dns_cache",
|
|
8743
|
-
title: "Flush DNS Cache",
|
|
8744
|
-
annotations: DESTRUCTIVE,
|
|
8745
|
-
description: "Clears all cached DNS entries from the router's resolver (`/ip dns cache flush`) \u2014 forces " + "the router to re-query upstream servers for all subsequent lookups. " + "Use after changing upstream servers or adding static records to evict stale cached responses. " + "To inspect the cache before flushing use get_dns_cache. " + "Returns confirmation of the flush.",
|
|
8746
|
-
async handler(_a, ctx) {
|
|
8747
|
-
ctx.info("Flushing DNS cache");
|
|
8748
|
-
const result = await executeMikrotikCommand("/ip dns cache flush", ctx);
|
|
8749
|
-
return result.trim() ? `Flush result: ${result}` : "DNS cache flushed successfully.";
|
|
8750
|
-
}
|
|
8751
|
-
}),
|
|
8752
|
-
defineTool({
|
|
8753
|
-
name: "get_dns_cache_statistics",
|
|
8754
|
-
title: "Get DNS Cache Statistics",
|
|
8755
|
-
annotations: READ,
|
|
8756
|
-
description: "Reports DNS cache utilization metrics \u2014 extracts cache-size, cache-used, and cache-max-ttl " + "from `/ip dns print`, then appends the live count of cached entries from " + "`/ip dns cache print count-only`. " + "Use to assess cache utilization without listing every entry. Works on RouterOS v6 and v7 " + "(there is no `/ip dns cache print stats` command). " + "To see all cached entries use get_dns_cache. " + "To change cache-size or cache-max-ttl use set_dns_servers. " + "Returns cache-related fields and the total entry count.",
|
|
8757
|
-
async handler(_a, ctx) {
|
|
8758
|
-
ctx.info("Getting DNS cache statistics");
|
|
8759
|
-
const settings = await executeMikrotikCommand("/ip dns print", ctx);
|
|
8760
|
-
if (looksLikeError(settings))
|
|
8761
|
-
return `Failed to get DNS cache statistics: ${settings}`;
|
|
8762
|
-
if (isEmpty(settings))
|
|
8763
|
-
return "Unable to retrieve DNS cache statistics.";
|
|
8764
|
-
const cacheLines = settings.split(`
|
|
8765
|
-
`).filter((l) => l.toLowerCase().includes("cache"));
|
|
8766
|
-
const stats = cacheLines.length ? cacheLines.join(`
|
|
8767
|
-
`) : settings.trim();
|
|
8768
|
-
const count = (await executeMikrotikCommand("/ip dns cache print count-only", ctx)).trim();
|
|
8769
|
-
const entryLine = /^\d+$/.test(count) ? `cached-entries: ${count}
|
|
8770
|
-
` : "";
|
|
8771
|
-
return `DNS CACHE STATISTICS:
|
|
8772
|
-
|
|
8773
|
-
${entryLine}${stats}`;
|
|
8774
|
-
}
|
|
8775
|
-
}),
|
|
8776
|
-
defineTool({
|
|
8777
|
-
name: "add_dns_regexp",
|
|
8778
|
-
title: "Add DNS Regexp Static Record",
|
|
8779
|
-
annotations: WRITE,
|
|
8780
|
-
description: "Creates a regexp-pattern static DNS record (`/ip dns static add`) that matches any hostname " + "satisfying the given regular expression and resolves it to a fixed IP address (IPv4 or IPv6) \u2014 useful for " + "wildcard domains, captive portals, or ad-blocking. " + "Simplified interface requiring only `regexp` (RouterOS regex) and `address` (IPv4 or IPv6), plus " + "optional `ttl` (default 1d), `comment`, and `disabled`. " + "For full record-type support (CNAME, MX, SRV, TXT) or to set both a hostname and regexp " + "use add_dns_static. " + "Returns the created entry's full detail including its `.id`.",
|
|
8781
|
-
inputSchema: {
|
|
8782
|
-
regexp: z21.string(),
|
|
8783
|
-
address: z21.string(),
|
|
8784
|
-
ttl: z21.string().default("1d"),
|
|
8785
|
-
comment: z21.string().optional(),
|
|
8786
|
-
disabled: z21.boolean().default(false)
|
|
8787
|
-
},
|
|
8788
|
-
handler: (a, ctx) => addDnsStatic({
|
|
8789
|
-
name: "dummy",
|
|
8790
|
-
address: a.address,
|
|
8791
|
-
regexp: a.regexp,
|
|
8792
|
-
ttl: a.ttl,
|
|
8793
|
-
comment: a.comment,
|
|
8794
|
-
disabled: a.disabled
|
|
8795
|
-
}, ctx)
|
|
8796
|
-
}),
|
|
8797
|
-
defineTool({
|
|
8798
|
-
name: "test_dns_query",
|
|
8799
|
-
title: "Test DNS Resolution From Router",
|
|
8800
|
-
annotations: READ,
|
|
8801
|
-
description: "Resolves a hostname using the router's own DNS resolver (`/resolve`) \u2014 tests what address " + "the router itself would obtain for a given name. Optionally directs the query to a specific " + 'upstream `server` (IP) and supports record types via `type` (e.g. "A", "AAAA", "MX"; ' + 'default "A"). ' + "Use to verify DNS reachability, that a static record override is active, or that DoH is " + "working \u2014 all from the router's perspective, not from a client behind it. " + "Returns the resolver's answer for the queried name and type.",
|
|
8802
|
-
inputSchema: {
|
|
8803
|
-
name: z21.string(),
|
|
8804
|
-
server: z21.string().optional(),
|
|
8805
|
-
type: z21.string().default("A")
|
|
8806
|
-
},
|
|
8807
|
-
async handler(a, ctx) {
|
|
8808
|
-
ctx.info(`Testing DNS query: name=${a.name}, type=${a.type}`);
|
|
8809
|
-
let cmd = `/resolve ${a.name}`;
|
|
8810
|
-
if (a.server)
|
|
8811
|
-
cmd += ` server=${a.server}`;
|
|
8812
|
-
if (a.type !== "A")
|
|
8813
|
-
cmd += ` type=${a.type}`;
|
|
8814
|
-
const result = await executeMikrotikCommand(cmd, ctx);
|
|
8815
|
-
return isEmpty(result) ? `Failed to resolve ${a.name}` : `DNS QUERY RESULT for ${a.name}:
|
|
8816
|
-
|
|
8817
|
-
${result}`;
|
|
8818
|
-
}
|
|
8819
|
-
}),
|
|
8820
|
-
defineTool({
|
|
8821
|
-
name: "export_dns_config",
|
|
8822
|
-
title: "Export DNS Configuration to File",
|
|
8823
|
-
annotations: READ,
|
|
8824
|
-
description: "Exports the full DNS configuration (resolver settings and static records) to a RouterOS " + "script file on the router's flash storage (`/ip dns export file=<filename>`). " + "The router appends `.rsc` to the filename automatically; defaults to `dns_config.rsc`. " + "The exported file can be imported on another RouterOS device to replicate DNS configuration. " + "To read current settings as structured output without writing a file use get_dns_settings. " + "To list static records use list_dns_static. " + "Returns the filename of the exported `.rsc` file on the router.",
|
|
8825
|
-
inputSchema: { filename: z21.string().optional() },
|
|
8826
|
-
async handler(a, ctx) {
|
|
8827
|
-
ctx.info("Exporting DNS configuration");
|
|
8828
|
-
const filename = a.filename || "dns_config";
|
|
8829
|
-
const result = await executeMikrotikCommand(`/ip dns export file=${filename}`, ctx);
|
|
8830
|
-
return result.trim() ? `Export result: ${result}` : `DNS configuration exported to ${filename}.rsc`;
|
|
8831
|
-
}
|
|
8832
|
-
})
|
|
8833
|
-
];
|
|
8834
|
-
|
|
8835
|
-
// src/tools/parental-controls.ts
|
|
8836
|
-
import { z as z22 } from "zod";
|
|
8837
|
-
function buildPolicyCommands(o) {
|
|
8838
|
-
const tag = `parental-${o.name}`;
|
|
8839
|
-
const groups = [];
|
|
8840
|
-
if (o.addresses?.length) {
|
|
8841
|
-
groups.push({
|
|
8842
|
-
label: "Target devices",
|
|
8843
|
-
commands: o.addresses.map((addr) => new Cmd("/ip firewall address-list add").set("list", o.list).set("address", addr).set("comment", tag).build())
|
|
8844
|
-
});
|
|
8845
|
-
}
|
|
8846
|
-
groups.push({
|
|
8847
|
-
label: "Scheduled internet cut-off",
|
|
8848
|
-
commands: [
|
|
8849
|
-
new Cmd("/ip firewall filter add").set("chain", "forward").set("src-address-list", o.list).set("action", "drop").set("comment", tag).set("disabled", "yes").build(),
|
|
8850
|
-
new Cmd("/system scheduler add").set("name", `${tag}-block`).set("start-time", o.blockStart).set("interval", "1d").set("on-event", `/ip firewall filter enable [find comment="${tag}"]`).set("comment", tag).build(),
|
|
8851
|
-
new Cmd("/system scheduler add").set("name", `${tag}-allow`).set("start-time", o.blockEnd).set("interval", "1d").set("on-event", `/ip firewall filter disable [find comment="${tag}"]`).set("comment", tag).build()
|
|
8852
|
-
]
|
|
8853
|
-
});
|
|
8854
|
-
if (o.blockDomains?.length) {
|
|
8855
|
-
groups.push({
|
|
8856
|
-
label: "Content blocking (DNS sinkhole)",
|
|
8857
|
-
commands: o.blockDomains.map((d) => new Cmd("/ip dns static add").set("name", d).set("address", "0.0.0.0").set("comment", tag).build())
|
|
8858
|
-
});
|
|
8859
|
-
}
|
|
8860
|
-
return groups;
|
|
8861
|
-
}
|
|
8862
|
-
var parentalControlsTools = [
|
|
8863
|
-
defineTool({
|
|
8864
|
-
name: "set_time_policy",
|
|
8865
|
-
title: "Set Time-of-Day / Parental Policy",
|
|
8866
|
-
annotations: WRITE,
|
|
8867
|
-
description: "Schedules per-device internet access and content blocking. Cuts internet for devices in the " + "`list` address-list between `block_start` and `block_end` daily (a forward-drop rule toggled by " + "two schedulers), optionally adds `addresses` to that list, and DNS-sinkholes any `block_domains` " + "(always blocked). DEFAULTS TO A DRY RUN (`apply=false`) showing every command; set `apply=true` " + "to install. Everything is tagged 'parental-<name>' so remove_time_policy can undo it. Returns " + "the plan or a build report.",
|
|
8868
|
-
inputSchema: {
|
|
8869
|
-
name: z22.string().describe("Policy id, e.g. 'kids-bedtime'"),
|
|
8870
|
-
list: z22.string().default("parental-devices").describe("Address-list of the affected devices"),
|
|
8871
|
-
addresses: z22.array(z22.string()).optional().describe("Device IPs to add to the list"),
|
|
8872
|
-
block_start: z22.string().default("22:00").describe("Daily cut-off time (HH:MM)"),
|
|
8873
|
-
block_end: z22.string().default("07:00").describe("Daily restore time (HH:MM)"),
|
|
8874
|
-
block_domains: z22.array(z22.string()).optional().describe("Domains to always block via DNS"),
|
|
8875
|
-
apply: z22.boolean().default(false).describe("false = preview (default); true = install")
|
|
8876
|
-
},
|
|
8877
|
-
async handler(a, ctx) {
|
|
8878
|
-
const groups = buildPolicyCommands({
|
|
8879
|
-
name: a.name,
|
|
8880
|
-
list: a.list,
|
|
8881
|
-
addresses: a.addresses,
|
|
8882
|
-
blockStart: a.block_start,
|
|
8883
|
-
blockEnd: a.block_end,
|
|
8884
|
-
blockDomains: a.block_domains
|
|
8885
|
-
});
|
|
8886
|
-
const all = groups.flatMap((g) => g.commands);
|
|
8887
|
-
if (!a.apply) {
|
|
8888
|
-
const preview = groups.map((g) => `# ${g.label}
|
|
8889
|
-
${g.commands.map((c) => ` ${c}`).join(`
|
|
8890
|
-
`)}`).join(`
|
|
8891
|
-
|
|
8892
|
-
`);
|
|
8893
|
-
return `DRY RUN \u2014 policy '${a.name}': block ${a.block_start}\u2013${a.block_end} for list '${a.list}'; ${all.length} command(s) (set apply=true to install):
|
|
8894
|
-
|
|
8895
|
-
${preview}`;
|
|
8896
|
-
}
|
|
8897
|
-
const done = [];
|
|
8898
|
-
for (const cmd of all) {
|
|
8899
|
-
const result = await executeMikrotikCommand(cmd, ctx);
|
|
8900
|
-
if (looksLikeError(result)) {
|
|
8901
|
-
return `Installed ${done.length}/${all.length}, then FAILED: ${result}`;
|
|
8902
|
-
}
|
|
8903
|
-
done.push(cmd);
|
|
8904
|
-
}
|
|
8905
|
-
return `Policy '${a.name}' installed \u2014 internet for '${a.list}' is blocked ${a.block_start}\u2013${a.block_end} daily${a.block_domains?.length ? `, and ${a.block_domains.length} domain(s) sinkholed` : ""}.`;
|
|
8906
|
-
}
|
|
8907
|
-
}),
|
|
8908
|
-
defineTool({
|
|
8909
|
-
name: "remove_time_policy",
|
|
8910
|
-
title: "Remove Time-of-Day / Parental Policy",
|
|
8911
|
-
annotations: DESTRUCTIVE,
|
|
8912
|
-
description: "Removes a policy installed by set_time_policy: the schedulers, the forward-drop rule, and the " + "DNS sinkhole entries tagged 'parental-<name>'. Address-list members added for it are also " + "removed. Returns what was cleared.",
|
|
8913
|
-
inputSchema: { name: z22.string().describe("The policy id used when installing") },
|
|
8914
|
-
async handler(a, ctx) {
|
|
8915
|
-
const tag = `parental-${a.name}`;
|
|
8916
|
-
ctx.info(`Removing parental policy ${tag}`);
|
|
8917
|
-
const steps = [
|
|
8918
|
-
["schedulers", `/system scheduler remove [find comment="${tag}"]`],
|
|
8919
|
-
["firewall rule", `/ip firewall filter remove [find comment="${tag}"]`],
|
|
8920
|
-
["dns sinkholes", `/ip dns static remove [find comment="${tag}"]`],
|
|
8921
|
-
["address-list", `/ip firewall address-list remove [find comment="${tag}"]`]
|
|
8922
|
-
];
|
|
8923
|
-
const cleared = [];
|
|
8924
|
-
for (const [label, cmd] of steps) {
|
|
8925
|
-
const r = await executeMikrotikCommand(cmd, ctx);
|
|
8926
|
-
if (looksLikeError(r))
|
|
8927
|
-
return `Failed removing ${label}: ${r} (cleared: ${cleared.join(", ") || "none"})`;
|
|
8928
|
-
cleared.push(label);
|
|
8929
|
-
}
|
|
8930
|
-
return `Policy '${a.name}' removed (${cleared.join(", ")}).`;
|
|
8931
|
-
}
|
|
8932
|
-
})
|
|
8933
|
-
];
|
|
8934
|
-
|
|
8935
|
-
// src/tools/dot1x-server.ts
|
|
8936
|
-
import { z as z23 } from "zod";
|
|
8937
|
-
var AuthTypes = z23.enum(["dot1x", "mac-auth", "dot1x,mac-auth"]);
|
|
8938
|
-
var MacAuthMode = z23.enum(["mac-as-username", "mac-as-username-and-password"]);
|
|
8939
|
-
var dot1xServerTools = [
|
|
8940
|
-
defineTool({
|
|
8941
|
-
name: "add_dot1x_server",
|
|
8942
|
-
title: "Add 802.1X Server (Authenticator) Entry",
|
|
8943
|
-
annotations: WRITE,
|
|
8944
|
-
description: "Creates an 802.1X authenticator entry on an interface (`/interface dot1x server add`), " + "enabling port-based network access control (IEEE 802.1X) enforced against a RADIUS server. " + "Use this to require EAP supplicant authentication (`dot1x`), MAC-bypass authentication " + "(`mac-auth`), or both on a physical port before granting network access. " + "For viewing existing entries use `list_dot1x_servers`; for modifying use `update_dot1x_server`; " + "for removal use `remove_dot1x_server`. " + `Returns the created entry's full detail.
|
|
8945
|
-
|
|
8946
|
-
` + `Notes:
|
|
8947
|
-
` + ` auth_types: 'dot1x' (EAP supplicant), 'mac-auth' (MAC bypass), or
|
|
8948
|
-
` + ` 'dot1x,mac-auth' (both).
|
|
8949
|
-
` + ` guest_vlan_id / reject_vlan_id / server_fail_vlan_id: VLAN to assign
|
|
8950
|
-
` + ` when there is no supplicant, on auth failure, or when RADIUS is
|
|
8951
|
-
` + ` unreachable (number, or 'none').
|
|
8952
|
-
` + " interim_update: RADIUS interim-accounting update interval, e.g. '5m' or '0s'.",
|
|
8953
|
-
inputSchema: {
|
|
8954
|
-
interface: z23.string(),
|
|
8955
|
-
auth_types: AuthTypes.optional(),
|
|
8956
|
-
accounting: z23.boolean().optional(),
|
|
8957
|
-
interim_update: z23.string().optional().describe("e.g. '5m' or '0s'"),
|
|
8958
|
-
mac_auth_mode: MacAuthMode.optional(),
|
|
8959
|
-
guest_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
8960
|
-
reject_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
8961
|
-
server_fail_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
8962
|
-
reauth_timeout: z23.string().optional().describe("Re-auth period or 'none'"),
|
|
8963
|
-
comment: z23.string().optional(),
|
|
8964
|
-
disabled: z23.boolean().default(false)
|
|
8965
|
-
},
|
|
8966
|
-
async handler(a, ctx) {
|
|
8967
|
-
ctx.info(`Adding dot1x server: interface=${a.interface}`);
|
|
8968
|
-
const cmd = new Cmd("/interface dot1x server add").set("interface", a.interface).opt("auth-types", a.auth_types).bool("accounting", a.accounting).opt("interim-update", a.interim_update).opt("mac-auth-mode", a.mac_auth_mode).opt("guest-vlan-id", a.guest_vlan_id).opt("reject-vlan-id", a.reject_vlan_id).opt("server-fail-vlan-id", a.server_fail_vlan_id).opt("reauth-timeout", a.reauth_timeout).opt("comment", a.comment).flag("disabled", a.disabled).build();
|
|
8969
|
-
const result = await executeMikrotikCommand(cmd, ctx);
|
|
8970
|
-
if (looksLikeError(result))
|
|
8971
|
-
return `Failed to add dot1x server: ${result}`;
|
|
8972
|
-
const details = await executeMikrotikCommand(`/interface dot1x server print detail where interface="${a.interface}"`, ctx);
|
|
8973
|
-
return details.trim() ? `Dot1x server added successfully:
|
|
8974
|
-
|
|
8975
|
-
${details}` : "Dot1x server addition completed but unable to verify.";
|
|
8976
|
-
}
|
|
9223
|
+
inputSchema: { entry_id: z21.string() },
|
|
9224
|
+
handler: (a, ctx) => updateDnsStatic({ entry_id: a.entry_id, disabled: true }, ctx)
|
|
8977
9225
|
}),
|
|
8978
9226
|
defineTool({
|
|
8979
|
-
name: "
|
|
8980
|
-
title: "
|
|
9227
|
+
name: "get_dns_cache",
|
|
9228
|
+
title: "Get DNS Cache Entries",
|
|
8981
9229
|
annotations: READ,
|
|
8982
|
-
description: "Lists all
|
|
8983
|
-
|
|
8984
|
-
|
|
8985
|
-
|
|
8986
|
-
|
|
8987
|
-
async handler(a, ctx) {
|
|
8988
|
-
ctx.info("Listing dot1x servers");
|
|
8989
|
-
const filters = [];
|
|
8990
|
-
if (a.interface_filter)
|
|
8991
|
-
filters.push(`interface="${a.interface_filter}"`);
|
|
8992
|
-
if (a.disabled_only)
|
|
8993
|
-
filters.push("disabled=yes");
|
|
8994
|
-
const result = await executeMikrotikCommand(`/interface dot1x server print${whereClause(filters)}`, ctx);
|
|
8995
|
-
return isEmpty(result) ? "No dot1x servers found matching the criteria." : `DOT1X SERVERS:
|
|
9230
|
+
description: "Lists all hostnames currently held in the router's DNS resolver cache (`/ip dns cache print`). " + "Use to inspect what the router has already resolved and what is available without re-querying " + "upstream servers. " + "For summary statistics (cache-size, cache-used, cache-max-ttl, total entry count) use " + "get_dns_cache_statistics. " + "To clear the cache use flush_dns_cache. " + "Returns the full cache table, or a message if the cache is empty.",
|
|
9231
|
+
async handler(_a, ctx) {
|
|
9232
|
+
ctx.info("Getting DNS cache");
|
|
9233
|
+
const result = await executeMikrotikCommand("/ip dns cache print", ctx);
|
|
9234
|
+
return isEmpty(result) ? "DNS cache is empty." : `DNS CACHE:
|
|
8996
9235
|
|
|
8997
9236
|
${result}`;
|
|
8998
9237
|
}
|
|
8999
9238
|
}),
|
|
9000
9239
|
defineTool({
|
|
9001
|
-
name: "
|
|
9002
|
-
title: "
|
|
9003
|
-
annotations:
|
|
9004
|
-
description: "
|
|
9005
|
-
|
|
9006
|
-
|
|
9007
|
-
|
|
9008
|
-
|
|
9009
|
-
ctx.info(`Getting dot1x server: server_id=${a.server_id}`);
|
|
9010
|
-
let result = await executeMikrotikCommand(`/interface dot1x server print detail where .id="${a.server_id}"`, ctx);
|
|
9011
|
-
if (isEmpty(result)) {
|
|
9012
|
-
result = await executeMikrotikCommand(`/interface dot1x server print detail where interface="${a.server_id}"`, ctx);
|
|
9013
|
-
}
|
|
9014
|
-
return isEmpty(result) ? `Dot1x server '${a.server_id}' not found.` : `DOT1X SERVER DETAILS:
|
|
9015
|
-
|
|
9016
|
-
${result}`;
|
|
9240
|
+
name: "flush_dns_cache",
|
|
9241
|
+
title: "Flush DNS Cache",
|
|
9242
|
+
annotations: DESTRUCTIVE,
|
|
9243
|
+
description: "Clears all cached DNS entries from the router's resolver (`/ip dns cache flush`) \u2014 forces " + "the router to re-query upstream servers for all subsequent lookups. " + "Use after changing upstream servers or adding static records to evict stale cached responses. " + "To inspect the cache before flushing use get_dns_cache. " + "Returns confirmation of the flush.",
|
|
9244
|
+
async handler(_a, ctx) {
|
|
9245
|
+
ctx.info("Flushing DNS cache");
|
|
9246
|
+
const result = await executeMikrotikCommand("/ip dns cache flush", ctx);
|
|
9247
|
+
return result.trim() ? `Flush result: ${result}` : "DNS cache flushed successfully.";
|
|
9017
9248
|
}
|
|
9018
9249
|
}),
|
|
9019
9250
|
defineTool({
|
|
9020
|
-
name: "
|
|
9021
|
-
title: "
|
|
9022
|
-
annotations:
|
|
9023
|
-
description: "
|
|
9024
|
-
|
|
9025
|
-
|
|
9026
|
-
|
|
9027
|
-
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
|
|
9032
|
-
|
|
9033
|
-
|
|
9034
|
-
|
|
9035
|
-
|
|
9036
|
-
|
|
9037
|
-
|
|
9038
|
-
|
|
9039
|
-
const selector = a.server_id.startsWith("*") ? `.id="${a.server_id}"` : `interface="${a.server_id}"`;
|
|
9040
|
-
const base = `/interface dot1x server set [find ${selector}]`;
|
|
9041
|
-
const cmd = new Cmd(base).opt("auth-types", a.auth_types).bool("accounting", a.accounting).opt("interim-update", a.interim_update).opt("mac-auth-mode", a.mac_auth_mode).opt("guest-vlan-id", a.guest_vlan_id).opt("reject-vlan-id", a.reject_vlan_id).opt("server-fail-vlan-id", a.server_fail_vlan_id).opt("reauth-timeout", a.reauth_timeout);
|
|
9042
|
-
if (a.comment !== undefined)
|
|
9043
|
-
cmd.raw(`comment=${quoteValue(a.comment)}`);
|
|
9044
|
-
if (a.disabled !== undefined)
|
|
9045
|
-
cmd.raw(`disabled=${yesno(a.disabled)}`);
|
|
9046
|
-
const built = cmd.build();
|
|
9047
|
-
if (built === base)
|
|
9048
|
-
return "No updates specified.";
|
|
9049
|
-
const result = await executeMikrotikCommand(built, ctx);
|
|
9050
|
-
if (looksLikeError(result))
|
|
9051
|
-
return `Failed to update dot1x server: ${result}`;
|
|
9052
|
-
const details = await executeMikrotikCommand(`/interface dot1x server print detail where ${selector}`, ctx);
|
|
9053
|
-
return `Dot1x server updated successfully:
|
|
9251
|
+
name: "get_dns_cache_statistics",
|
|
9252
|
+
title: "Get DNS Cache Statistics",
|
|
9253
|
+
annotations: READ,
|
|
9254
|
+
description: "Reports DNS cache utilization metrics \u2014 extracts cache-size, cache-used, and cache-max-ttl " + "from `/ip dns print`, then appends the live count of cached entries from " + "`/ip dns cache print count-only`. " + "Use to assess cache utilization without listing every entry. Works on RouterOS v6 and v7 " + "(there is no `/ip dns cache print stats` command). " + "To see all cached entries use get_dns_cache. " + "To change cache-size or cache-max-ttl use set_dns_servers. " + "Returns cache-related fields and the total entry count.",
|
|
9255
|
+
async handler(_a, ctx) {
|
|
9256
|
+
ctx.info("Getting DNS cache statistics");
|
|
9257
|
+
const settings = await executeMikrotikCommand("/ip dns print", ctx);
|
|
9258
|
+
if (looksLikeError(settings))
|
|
9259
|
+
return `Failed to get DNS cache statistics: ${settings}`;
|
|
9260
|
+
if (isEmpty(settings))
|
|
9261
|
+
return "Unable to retrieve DNS cache statistics.";
|
|
9262
|
+
const cacheLines = settings.split(`
|
|
9263
|
+
`).filter((l) => l.toLowerCase().includes("cache"));
|
|
9264
|
+
const stats = cacheLines.length ? cacheLines.join(`
|
|
9265
|
+
`) : settings.trim();
|
|
9266
|
+
const count = (await executeMikrotikCommand("/ip dns cache print count-only", ctx)).trim();
|
|
9267
|
+
const entryLine = /^\d+$/.test(count) ? `cached-entries: ${count}
|
|
9268
|
+
` : "";
|
|
9269
|
+
return `DNS CACHE STATISTICS:
|
|
9054
9270
|
|
|
9055
|
-
${
|
|
9271
|
+
${entryLine}${stats}`;
|
|
9056
9272
|
}
|
|
9057
9273
|
}),
|
|
9058
9274
|
defineTool({
|
|
9059
|
-
name: "
|
|
9060
|
-
title: "
|
|
9061
|
-
annotations: DESTRUCTIVE,
|
|
9062
|
-
description: "Permanently removes an 802.1X authenticator entry (`/interface dot1x server remove`) identified by interface name or RouterOS `.id`. " + "Pass `server_id` as a `.id` string (starts with `*`, e.g. `*1` \u2014 obtain from `list_dot1x_servers`) or as the interface name (e.g. `ether2`). " + "Verifies the entry exists via a `count-only` check before deletion \u2014 returns a not-found message if absent. " + "For creating an entry use `add_dot1x_server`; for non-destructive disabling use `update_dot1x_server` with `disabled=true`.",
|
|
9063
|
-
inputSchema: {
|
|
9064
|
-
server_id: z23.string().describe("Interface name or RouterOS '.id'")
|
|
9065
|
-
},
|
|
9066
|
-
async handler(a, ctx) {
|
|
9067
|
-
ctx.info(`Removing dot1x server: server_id=${a.server_id}`);
|
|
9068
|
-
const selector = a.server_id.startsWith("*") ? `.id="${a.server_id}"` : `interface="${a.server_id}"`;
|
|
9069
|
-
const count = await executeMikrotikCommand(`/interface dot1x server print count-only where ${selector}`, ctx);
|
|
9070
|
-
if (count.trim() === "0")
|
|
9071
|
-
return `Dot1x server '${a.server_id}' not found.`;
|
|
9072
|
-
const result = await executeMikrotikCommand(`/interface dot1x server remove [find ${selector}]`, ctx);
|
|
9073
|
-
if (looksLikeError(result))
|
|
9074
|
-
return `Failed to remove dot1x server: ${result}`;
|
|
9075
|
-
return `Dot1x server '${a.server_id}' removed successfully.`;
|
|
9076
|
-
}
|
|
9077
|
-
})
|
|
9078
|
-
];
|
|
9079
|
-
|
|
9080
|
-
// src/tools/dot1x-client.ts
|
|
9081
|
-
import { z as z24 } from "zod";
|
|
9082
|
-
var dot1xClientTools = [
|
|
9083
|
-
defineTool({
|
|
9084
|
-
name: "add_dot1x_client",
|
|
9085
|
-
title: "Add 802.1X Supplicant Client",
|
|
9275
|
+
name: "add_dns_regexp",
|
|
9276
|
+
title: "Add DNS Regexp Static Record",
|
|
9086
9277
|
annotations: WRITE,
|
|
9087
|
-
description: "
|
|
9278
|
+
description: "Creates a regexp-pattern static DNS record (`/ip dns static add`) that matches any hostname " + "satisfying the given regular expression and resolves it to a fixed IP address (IPv4 or IPv6) \u2014 useful for " + "wildcard domains, captive portals, or ad-blocking. " + "Simplified interface requiring only `regexp` (RouterOS regex) and `address` (IPv4 or IPv6), plus " + "optional `ttl` (default 1d), `comment`, and `disabled`. " + "For full record-type support (CNAME, MX, SRV, TXT) or to set both a hostname and regexp " + "use add_dns_static. " + "Returns the created entry's full detail including its `.id`.",
|
|
9088
9279
|
inputSchema: {
|
|
9089
|
-
|
|
9090
|
-
|
|
9091
|
-
|
|
9092
|
-
|
|
9093
|
-
|
|
9094
|
-
password: z24.string().optional().describe("EAP password (password methods)"),
|
|
9095
|
-
comment: z24.string().optional(),
|
|
9096
|
-
disabled: z24.boolean().default(false)
|
|
9280
|
+
regexp: z21.string(),
|
|
9281
|
+
address: z21.string(),
|
|
9282
|
+
ttl: z21.string().default("1d"),
|
|
9283
|
+
comment: z21.string().optional(),
|
|
9284
|
+
disabled: z21.boolean().default(false)
|
|
9097
9285
|
},
|
|
9098
|
-
|
|
9099
|
-
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
|
|
9103
|
-
|
|
9104
|
-
|
|
9105
|
-
|
|
9106
|
-
|
|
9107
|
-
${details}` : "Dot1x client addition completed but unable to verify.";
|
|
9108
|
-
}
|
|
9286
|
+
handler: (a, ctx) => addDnsStatic({
|
|
9287
|
+
name: "dummy",
|
|
9288
|
+
address: a.address,
|
|
9289
|
+
regexp: a.regexp,
|
|
9290
|
+
ttl: a.ttl,
|
|
9291
|
+
comment: a.comment,
|
|
9292
|
+
disabled: a.disabled
|
|
9293
|
+
}, ctx)
|
|
9109
9294
|
}),
|
|
9110
9295
|
defineTool({
|
|
9111
|
-
name: "
|
|
9112
|
-
title: "
|
|
9296
|
+
name: "test_dns_query",
|
|
9297
|
+
title: "Test DNS Resolution From Router",
|
|
9113
9298
|
annotations: READ,
|
|
9114
|
-
description: "
|
|
9299
|
+
description: "Resolves a hostname using the router's own DNS resolver (`/resolve`) \u2014 tests what address " + "the router itself would obtain for a given name. Optionally directs the query to a specific " + 'upstream `server` (IP) and supports record types via `type` (e.g. "A", "AAAA", "MX"; ' + 'default "A"). ' + "Use to verify DNS reachability, that a static record override is active, or that DoH is " + "working \u2014 all from the router's perspective, not from a client behind it. " + "Returns the resolver's answer for the queried name and type.",
|
|
9115
9300
|
inputSchema: {
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
|
|
9301
|
+
name: z21.string(),
|
|
9302
|
+
server: z21.string().optional(),
|
|
9303
|
+
type: z21.string().default("A")
|
|
9119
9304
|
},
|
|
9120
9305
|
async handler(a, ctx) {
|
|
9121
|
-
ctx.info(
|
|
9122
|
-
|
|
9123
|
-
if (a.
|
|
9124
|
-
|
|
9125
|
-
if (a.
|
|
9126
|
-
|
|
9127
|
-
|
|
9128
|
-
|
|
9129
|
-
const result = await executeMikrotikCommand(`/interface dot1x client print${whereClause(filters)}`, ctx);
|
|
9130
|
-
return isEmpty(result) ? "No dot1x clients found matching the criteria." : `DOT1X CLIENTS:
|
|
9306
|
+
ctx.info(`Testing DNS query: name=${a.name}, type=${a.type}`);
|
|
9307
|
+
let cmd = `/resolve ${a.name}`;
|
|
9308
|
+
if (a.server)
|
|
9309
|
+
cmd += ` server=${a.server}`;
|
|
9310
|
+
if (a.type !== "A")
|
|
9311
|
+
cmd += ` type=${a.type}`;
|
|
9312
|
+
const result = await executeMikrotikCommand(cmd, ctx);
|
|
9313
|
+
return isEmpty(result) ? `Failed to resolve ${a.name}` : `DNS QUERY RESULT for ${a.name}:
|
|
9131
9314
|
|
|
9132
9315
|
${result}`;
|
|
9133
9316
|
}
|
|
9134
9317
|
}),
|
|
9135
9318
|
defineTool({
|
|
9136
|
-
name: "
|
|
9137
|
-
title: "
|
|
9319
|
+
name: "export_dns_config",
|
|
9320
|
+
title: "Export DNS Configuration to File",
|
|
9138
9321
|
annotations: READ,
|
|
9139
|
-
description: "
|
|
9140
|
-
inputSchema: {
|
|
9141
|
-
client_id: z24.string().describe("Interface name (e.g. 'ether3') or RouterOS '.id'")
|
|
9142
|
-
},
|
|
9322
|
+
description: "Exports the full DNS configuration (resolver settings and static records) to a RouterOS " + "script file on the router's flash storage (`/ip dns export file=<filename>`). " + "The router appends `.rsc` to the filename automatically; defaults to `dns_config.rsc`. " + "The exported file can be imported on another RouterOS device to replicate DNS configuration. " + "To read current settings as structured output without writing a file use get_dns_settings. " + "To list static records use list_dns_static. " + "Returns the filename of the exported `.rsc` file on the router.",
|
|
9323
|
+
inputSchema: { filename: z21.string().optional() },
|
|
9143
9324
|
async handler(a, ctx) {
|
|
9144
|
-
ctx.info(
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
|
|
9148
|
-
}
|
|
9149
|
-
return isEmpty(result) ? `Dot1x client '${a.client_id}' not found.` : `DOT1X CLIENT DETAILS:
|
|
9150
|
-
|
|
9151
|
-
${result}`;
|
|
9325
|
+
ctx.info("Exporting DNS configuration");
|
|
9326
|
+
const filename = a.filename || "dns_config";
|
|
9327
|
+
const result = await executeMikrotikCommand(`/ip dns export file=${filename}`, ctx);
|
|
9328
|
+
return result.trim() ? `Export result: ${result}` : `DNS configuration exported to ${filename}.rsc`;
|
|
9152
9329
|
}
|
|
9153
|
-
})
|
|
9154
|
-
|
|
9155
|
-
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9330
|
+
})
|
|
9331
|
+
];
|
|
9332
|
+
|
|
9333
|
+
// src/tools/parental-controls.ts
|
|
9334
|
+
import { z as z22 } from "zod";
|
|
9335
|
+
function buildPolicyCommands(o) {
|
|
9336
|
+
const tag = `parental-${o.name}`;
|
|
9337
|
+
const groups = [];
|
|
9338
|
+
if (o.addresses?.length) {
|
|
9339
|
+
groups.push({
|
|
9340
|
+
label: "Target devices",
|
|
9341
|
+
commands: o.addresses.map((addr) => new Cmd("/ip firewall address-list add").set("list", o.list).set("address", addr).set("comment", tag).build())
|
|
9342
|
+
});
|
|
9343
|
+
}
|
|
9344
|
+
groups.push({
|
|
9345
|
+
label: "Scheduled internet cut-off",
|
|
9346
|
+
commands: [
|
|
9347
|
+
new Cmd("/ip firewall filter add").set("chain", "forward").set("src-address-list", o.list).set("action", "drop").set("comment", tag).set("disabled", "yes").build(),
|
|
9348
|
+
new Cmd("/system scheduler add").set("name", `${tag}-block`).set("start-time", o.blockStart).set("interval", "1d").set("on-event", `/ip firewall filter enable [find comment="${tag}"]`).set("comment", tag).build(),
|
|
9349
|
+
new Cmd("/system scheduler add").set("name", `${tag}-allow`).set("start-time", o.blockEnd).set("interval", "1d").set("on-event", `/ip firewall filter disable [find comment="${tag}"]`).set("comment", tag).build()
|
|
9350
|
+
]
|
|
9351
|
+
});
|
|
9352
|
+
if (o.blockDomains?.length) {
|
|
9353
|
+
groups.push({
|
|
9354
|
+
label: "Content blocking (DNS sinkhole)",
|
|
9355
|
+
commands: o.blockDomains.map((d) => new Cmd("/ip dns static add").set("name", d).set("address", "0.0.0.0").set("comment", tag).build())
|
|
9356
|
+
});
|
|
9357
|
+
}
|
|
9358
|
+
return groups;
|
|
9359
|
+
}
|
|
9360
|
+
var parentalControlsTools = [
|
|
9361
|
+
defineTool({
|
|
9362
|
+
name: "set_time_policy",
|
|
9363
|
+
title: "Set Time-of-Day / Parental Policy",
|
|
9364
|
+
annotations: WRITE,
|
|
9365
|
+
description: "Schedules per-device internet access and content blocking. Cuts internet for devices in the " + "`list` address-list between `block_start` and `block_end` daily (a forward-drop rule toggled by " + "two schedulers), optionally adds `addresses` to that list, and DNS-sinkholes any `block_domains` " + "(always blocked). DEFAULTS TO A DRY RUN (`apply=false`) showing every command; set `apply=true` " + "to install. Everything is tagged 'parental-<name>' so remove_time_policy can undo it. Returns " + "the plan or a build report.",
|
|
9159
9366
|
inputSchema: {
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
disabled: z24.boolean().optional()
|
|
9367
|
+
name: z22.string().describe("Policy id, e.g. 'kids-bedtime'"),
|
|
9368
|
+
list: z22.string().default("parental-devices").describe("Address-list of the affected devices"),
|
|
9369
|
+
addresses: z22.array(z22.string()).optional().describe("Device IPs to add to the list"),
|
|
9370
|
+
block_start: z22.string().default("22:00").describe("Daily cut-off time (HH:MM)"),
|
|
9371
|
+
block_end: z22.string().default("07:00").describe("Daily restore time (HH:MM)"),
|
|
9372
|
+
block_domains: z22.array(z22.string()).optional().describe("Domains to always block via DNS"),
|
|
9373
|
+
apply: z22.boolean().default(false).describe("false = preview (default); true = install")
|
|
9168
9374
|
},
|
|
9169
9375
|
async handler(a, ctx) {
|
|
9170
|
-
|
|
9171
|
-
|
|
9172
|
-
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
const
|
|
9179
|
-
if (
|
|
9180
|
-
|
|
9181
|
-
|
|
9182
|
-
|
|
9183
|
-
return `Failed to update dot1x client: ${result}`;
|
|
9184
|
-
const details = await executeMikrotikCommand(`/interface dot1x client print detail where ${selector}`, ctx);
|
|
9185
|
-
return `Dot1x client updated successfully:
|
|
9376
|
+
const groups = buildPolicyCommands({
|
|
9377
|
+
name: a.name,
|
|
9378
|
+
list: a.list,
|
|
9379
|
+
addresses: a.addresses,
|
|
9380
|
+
blockStart: a.block_start,
|
|
9381
|
+
blockEnd: a.block_end,
|
|
9382
|
+
blockDomains: a.block_domains
|
|
9383
|
+
});
|
|
9384
|
+
const all = groups.flatMap((g) => g.commands);
|
|
9385
|
+
if (!a.apply) {
|
|
9386
|
+
const preview = groups.map((g) => `# ${g.label}
|
|
9387
|
+
${g.commands.map((c) => ` ${c}`).join(`
|
|
9388
|
+
`)}`).join(`
|
|
9186
9389
|
|
|
9187
|
-
|
|
9390
|
+
`);
|
|
9391
|
+
return `DRY RUN \u2014 policy '${a.name}': block ${a.block_start}\u2013${a.block_end} for list '${a.list}'; ${all.length} command(s) (set apply=true to install):
|
|
9392
|
+
|
|
9393
|
+
${preview}`;
|
|
9394
|
+
}
|
|
9395
|
+
const done = [];
|
|
9396
|
+
for (const cmd of all) {
|
|
9397
|
+
const result = await executeMikrotikCommand(cmd, ctx);
|
|
9398
|
+
if (looksLikeError(result)) {
|
|
9399
|
+
return `Installed ${done.length}/${all.length}, then FAILED: ${result}`;
|
|
9400
|
+
}
|
|
9401
|
+
done.push(cmd);
|
|
9402
|
+
}
|
|
9403
|
+
return `Policy '${a.name}' installed \u2014 internet for '${a.list}' is blocked ${a.block_start}\u2013${a.block_end} daily${a.block_domains?.length ? `, and ${a.block_domains.length} domain(s) sinkholed` : ""}.`;
|
|
9188
9404
|
}
|
|
9189
9405
|
}),
|
|
9190
9406
|
defineTool({
|
|
9191
|
-
name: "
|
|
9192
|
-
title: "Remove
|
|
9407
|
+
name: "remove_time_policy",
|
|
9408
|
+
title: "Remove Time-of-Day / Parental Policy",
|
|
9193
9409
|
annotations: DESTRUCTIVE,
|
|
9194
|
-
description: "Removes
|
|
9195
|
-
inputSchema: {
|
|
9196
|
-
client_id: z24.string().describe("Interface name or RouterOS '.id'")
|
|
9197
|
-
},
|
|
9410
|
+
description: "Removes a policy installed by set_time_policy: the schedulers, the forward-drop rule, and the " + "DNS sinkhole entries tagged 'parental-<name>'. Address-list members added for it are also " + "removed. Returns what was cleared.",
|
|
9411
|
+
inputSchema: { name: z22.string().describe("The policy id used when installing") },
|
|
9198
9412
|
async handler(a, ctx) {
|
|
9199
|
-
|
|
9200
|
-
|
|
9201
|
-
const
|
|
9202
|
-
|
|
9203
|
-
|
|
9204
|
-
|
|
9205
|
-
|
|
9206
|
-
|
|
9207
|
-
|
|
9413
|
+
const tag = `parental-${a.name}`;
|
|
9414
|
+
ctx.info(`Removing parental policy ${tag}`);
|
|
9415
|
+
const steps = [
|
|
9416
|
+
["schedulers", `/system scheduler remove [find comment="${tag}"]`],
|
|
9417
|
+
["firewall rule", `/ip firewall filter remove [find comment="${tag}"]`],
|
|
9418
|
+
["dns sinkholes", `/ip dns static remove [find comment="${tag}"]`],
|
|
9419
|
+
["address-list", `/ip firewall address-list remove [find comment="${tag}"]`]
|
|
9420
|
+
];
|
|
9421
|
+
const cleared = [];
|
|
9422
|
+
for (const [label, cmd] of steps) {
|
|
9423
|
+
const r = await executeMikrotikCommand(cmd, ctx);
|
|
9424
|
+
if (looksLikeError(r))
|
|
9425
|
+
return `Failed removing ${label}: ${r} (cleared: ${cleared.join(", ") || "none"})`;
|
|
9426
|
+
cleared.push(label);
|
|
9427
|
+
}
|
|
9428
|
+
return `Policy '${a.name}' removed (${cleared.join(", ")}).`;
|
|
9208
9429
|
}
|
|
9209
9430
|
})
|
|
9210
9431
|
];
|
|
9211
9432
|
|
|
9212
|
-
// src/tools/
|
|
9213
|
-
import { z as
|
|
9433
|
+
// src/tools/dot1x-server.ts
|
|
9434
|
+
import { z as z23 } from "zod";
|
|
9435
|
+
var AuthTypes = z23.enum(["dot1x", "mac-auth", "dot1x,mac-auth"]);
|
|
9436
|
+
var MacAuthMode = z23.enum(["mac-as-username", "mac-as-username-and-password"]);
|
|
9437
|
+
var dot1xServerTools = [
|
|
9438
|
+
defineTool({
|
|
9439
|
+
name: "add_dot1x_server",
|
|
9440
|
+
title: "Add 802.1X Server (Authenticator) Entry",
|
|
9441
|
+
annotations: WRITE,
|
|
9442
|
+
description: "Creates an 802.1X authenticator entry on an interface (`/interface dot1x server add`), " + "enabling port-based network access control (IEEE 802.1X) enforced against a RADIUS server. " + "Use this to require EAP supplicant authentication (`dot1x`), MAC-bypass authentication " + "(`mac-auth`), or both on a physical port before granting network access. " + "For viewing existing entries use `list_dot1x_servers`; for modifying use `update_dot1x_server`; " + "for removal use `remove_dot1x_server`. " + `Returns the created entry's full detail.
|
|
9214
9443
|
|
|
9215
|
-
|
|
9216
|
-
|
|
9217
|
-
|
|
9218
|
-
|
|
9219
|
-
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9225
|
-
|
|
9226
|
-
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
|
|
9231
|
-
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
|
|
9235
|
-
|
|
9236
|
-
|
|
9237
|
-
|
|
9238
|
-
|
|
9239
|
-
|
|
9240
|
-
|
|
9241
|
-
|
|
9242
|
-
|
|
9243
|
-
|
|
9244
|
-
|
|
9245
|
-
"new-priority",
|
|
9246
|
-
"new-ttl",
|
|
9247
|
-
"jump-target"
|
|
9248
|
-
]);
|
|
9249
|
-
var NONDETERMINISTIC = new Set([
|
|
9250
|
-
"limit",
|
|
9251
|
-
"dst-limit",
|
|
9252
|
-
"random",
|
|
9253
|
-
"nth",
|
|
9254
|
-
"psd",
|
|
9255
|
-
"connection-bytes",
|
|
9256
|
-
"connection-rate",
|
|
9257
|
-
"rate",
|
|
9258
|
-
"time",
|
|
9259
|
-
"content",
|
|
9260
|
-
"layer7-protocol",
|
|
9261
|
-
"tls-host"
|
|
9262
|
-
]);
|
|
9263
|
-
var TERMINAL = new Set(["accept", "drop", "reject", "tarpit"]);
|
|
9264
|
-
var ADDRESS_KEYS = new Set(["src-address", "dst-address"]);
|
|
9265
|
-
var CATCH_ALL_ADDR = new Set(["0.0.0.0/0", "::/0"]);
|
|
9266
|
-
function rulesFromRows(rows) {
|
|
9267
|
-
return rows.map((r, i) => {
|
|
9268
|
-
const flags = r.flags ?? "";
|
|
9269
|
-
const match = {};
|
|
9270
|
-
const transform = {};
|
|
9271
|
-
for (const [k, v] of Object.entries(r)) {
|
|
9272
|
-
if (!v || NON_MATCH.has(k))
|
|
9273
|
-
continue;
|
|
9274
|
-
if (TRANSFORM_KEYS.has(k))
|
|
9275
|
-
transform[k] = v;
|
|
9276
|
-
else
|
|
9277
|
-
match[k] = v;
|
|
9278
|
-
}
|
|
9279
|
-
const num2 = (s) => {
|
|
9280
|
-
if (s == null)
|
|
9281
|
-
return;
|
|
9282
|
-
const n = Number(s.replace(/\s/g, ""));
|
|
9283
|
-
return Number.isFinite(n) ? n : undefined;
|
|
9284
|
-
};
|
|
9285
|
-
return {
|
|
9286
|
-
index: r["#"] != null && /^\d+$/.test(r["#"]) ? Number(r["#"]) : i,
|
|
9287
|
-
chain: r.chain ?? "?",
|
|
9288
|
-
action: r.action ?? "?",
|
|
9289
|
-
disabled: flags.includes("X"),
|
|
9290
|
-
dynamic: flags.includes("D"),
|
|
9291
|
-
comment: r.comment,
|
|
9292
|
-
packets: num2(r.packets),
|
|
9293
|
-
bytes: num2(r.bytes),
|
|
9294
|
-
match,
|
|
9295
|
-
transform,
|
|
9296
|
-
raw: r
|
|
9297
|
-
};
|
|
9298
|
-
});
|
|
9299
|
-
}
|
|
9300
|
-
function toCidr(value) {
|
|
9301
|
-
try {
|
|
9302
|
-
if (value.includes("/"))
|
|
9303
|
-
return ipaddr.parseCIDR(value);
|
|
9304
|
-
const addr = ipaddr.parse(value);
|
|
9305
|
-
return [addr, addr.kind() === "ipv6" ? 128 : 32];
|
|
9306
|
-
} catch {
|
|
9307
|
-
return null;
|
|
9308
|
-
}
|
|
9309
|
-
}
|
|
9310
|
-
function cidrContains(a, b) {
|
|
9311
|
-
const A = toCidr(a);
|
|
9312
|
-
const B = toCidr(b);
|
|
9313
|
-
if (!A || !B)
|
|
9314
|
-
return false;
|
|
9315
|
-
const [aAddr, aBits] = A;
|
|
9316
|
-
const [bAddr, bBits] = B;
|
|
9317
|
-
if (aAddr.kind() !== bAddr.kind())
|
|
9318
|
-
return false;
|
|
9319
|
-
if (aBits > bBits)
|
|
9320
|
-
return false;
|
|
9321
|
-
try {
|
|
9322
|
-
return bAddr.match(aAddr, aBits);
|
|
9323
|
-
} catch {
|
|
9324
|
-
return false;
|
|
9325
|
-
}
|
|
9326
|
-
}
|
|
9327
|
-
function covers(key, aVal, bVal) {
|
|
9328
|
-
if (aVal === bVal)
|
|
9329
|
-
return true;
|
|
9330
|
-
if (ADDRESS_KEYS.has(key))
|
|
9331
|
-
return cidrContains(aVal, bVal);
|
|
9332
|
-
return false;
|
|
9333
|
-
}
|
|
9334
|
-
var INTERFACE_LIST_PAIRS = [
|
|
9335
|
-
["in-interface-list", "in-interface"],
|
|
9336
|
-
["out-interface-list", "out-interface"]
|
|
9337
|
-
];
|
|
9338
|
-
var _ifaceLists;
|
|
9339
|
-
function interfaceListCovers(aKey, aVal, bKey, bVal) {
|
|
9340
|
-
if (!_ifaceLists)
|
|
9341
|
-
return;
|
|
9342
|
-
for (const [listKey, ifaceKey] of INTERFACE_LIST_PAIRS) {
|
|
9343
|
-
if (aKey === listKey && bKey === ifaceKey) {
|
|
9344
|
-
const negated = aVal.startsWith("!");
|
|
9345
|
-
const listName = negated ? aVal.slice(1) : aVal;
|
|
9346
|
-
const members = _ifaceLists.get(listName);
|
|
9347
|
-
if (!members)
|
|
9348
|
-
return;
|
|
9349
|
-
const isMember = members.has(bVal);
|
|
9350
|
-
return negated ? !isMember : isMember;
|
|
9444
|
+
` + `Notes:
|
|
9445
|
+
` + ` auth_types: 'dot1x' (EAP supplicant), 'mac-auth' (MAC bypass), or
|
|
9446
|
+
` + ` 'dot1x,mac-auth' (both).
|
|
9447
|
+
` + ` guest_vlan_id / reject_vlan_id / server_fail_vlan_id: VLAN to assign
|
|
9448
|
+
` + ` when there is no supplicant, on auth failure, or when RADIUS is
|
|
9449
|
+
` + ` unreachable (number, or 'none').
|
|
9450
|
+
` + " interim_update: RADIUS interim-accounting update interval, e.g. '5m' or '0s'.",
|
|
9451
|
+
inputSchema: {
|
|
9452
|
+
interface: z23.string(),
|
|
9453
|
+
auth_types: AuthTypes.optional(),
|
|
9454
|
+
accounting: z23.boolean().optional(),
|
|
9455
|
+
interim_update: z23.string().optional().describe("e.g. '5m' or '0s'"),
|
|
9456
|
+
mac_auth_mode: MacAuthMode.optional(),
|
|
9457
|
+
guest_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
9458
|
+
reject_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
9459
|
+
server_fail_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
9460
|
+
reauth_timeout: z23.string().optional().describe("Re-auth period or 'none'"),
|
|
9461
|
+
comment: z23.string().optional(),
|
|
9462
|
+
disabled: z23.boolean().default(false)
|
|
9463
|
+
},
|
|
9464
|
+
async handler(a, ctx) {
|
|
9465
|
+
ctx.info(`Adding dot1x server: interface=${a.interface}`);
|
|
9466
|
+
const cmd = new Cmd("/interface dot1x server add").set("interface", a.interface).opt("auth-types", a.auth_types).bool("accounting", a.accounting).opt("interim-update", a.interim_update).opt("mac-auth-mode", a.mac_auth_mode).opt("guest-vlan-id", a.guest_vlan_id).opt("reject-vlan-id", a.reject_vlan_id).opt("server-fail-vlan-id", a.server_fail_vlan_id).opt("reauth-timeout", a.reauth_timeout).opt("comment", a.comment).flag("disabled", a.disabled).build();
|
|
9467
|
+
const result = await executeMikrotikCommand(cmd, ctx);
|
|
9468
|
+
if (looksLikeError(result))
|
|
9469
|
+
return `Failed to add dot1x server: ${result}`;
|
|
9470
|
+
const details = await executeMikrotikCommand(`/interface dot1x server print detail where interface="${a.interface}"`, ctx);
|
|
9471
|
+
return details.trim() ? `Dot1x server added successfully:
|
|
9472
|
+
|
|
9473
|
+
${details}` : "Dot1x server addition completed but unable to verify.";
|
|
9351
9474
|
}
|
|
9352
|
-
}
|
|
9353
|
-
|
|
9354
|
-
|
|
9355
|
-
|
|
9356
|
-
|
|
9357
|
-
|
|
9358
|
-
|
|
9359
|
-
|
|
9360
|
-
|
|
9361
|
-
|
|
9475
|
+
}),
|
|
9476
|
+
defineTool({
|
|
9477
|
+
name: "list_dot1x_servers",
|
|
9478
|
+
title: "List 802.1X Server (Authenticator) Entries",
|
|
9479
|
+
annotations: READ,
|
|
9480
|
+
description: "Lists all 802.1X authenticator entries (`/interface dot1x server print`). " + "Optionally filter by interface name (`interface_filter`) or restrict output to disabled entries only (`disabled_only=true`). " + "For full detail on a single entry use `get_dot1x_server`. " + "Returns the tabular RouterOS print output including each entry's `.id`, interface, auth-types, and VLAN assignments.",
|
|
9481
|
+
inputSchema: {
|
|
9482
|
+
interface_filter: z23.string().optional(),
|
|
9483
|
+
disabled_only: z23.boolean().default(false)
|
|
9484
|
+
},
|
|
9485
|
+
async handler(a, ctx) {
|
|
9486
|
+
ctx.info("Listing dot1x servers");
|
|
9487
|
+
const filters = [];
|
|
9488
|
+
if (a.interface_filter)
|
|
9489
|
+
filters.push(`interface="${a.interface_filter}"`);
|
|
9490
|
+
if (a.disabled_only)
|
|
9491
|
+
filters.push("disabled=yes");
|
|
9492
|
+
const result = await executeMikrotikCommand(`/interface dot1x server print${whereClause(filters)}`, ctx);
|
|
9493
|
+
return isEmpty(result) ? "No dot1x servers found matching the criteria." : `DOT1X SERVERS:
|
|
9494
|
+
|
|
9495
|
+
${result}`;
|
|
9362
9496
|
}
|
|
9363
|
-
|
|
9364
|
-
|
|
9365
|
-
|
|
9366
|
-
|
|
9367
|
-
|
|
9368
|
-
|
|
9497
|
+
}),
|
|
9498
|
+
defineTool({
|
|
9499
|
+
name: "get_dot1x_server",
|
|
9500
|
+
title: "Get 802.1X Server (Authenticator) Entry Detail",
|
|
9501
|
+
annotations: READ,
|
|
9502
|
+
description: "Retrieves full detail of a single 802.1X authenticator entry (`/interface dot1x server print detail`). " + "Accepts either the RouterOS `.id` (e.g. `*1`, from `list_dot1x_servers`) or the interface name (e.g. `ether2`) as `server_id`; " + "tries `.id` lookup first, then falls back to interface-name lookup. " + "For a summary list of all entries use `list_dot1x_servers`. " + "Returns the detailed entry or a not-found message if no match exists.",
|
|
9503
|
+
inputSchema: {
|
|
9504
|
+
server_id: z23.string().describe("Interface name (e.g. 'ether2') or RouterOS '.id'")
|
|
9505
|
+
},
|
|
9506
|
+
async handler(a, ctx) {
|
|
9507
|
+
ctx.info(`Getting dot1x server: server_id=${a.server_id}`);
|
|
9508
|
+
let result = await executeMikrotikCommand(`/interface dot1x server print detail where .id="${a.server_id}"`, ctx);
|
|
9509
|
+
if (isEmpty(result)) {
|
|
9510
|
+
result = await executeMikrotikCommand(`/interface dot1x server print detail where interface="${a.server_id}"`, ctx);
|
|
9369
9511
|
}
|
|
9512
|
+
return isEmpty(result) ? `Dot1x server '${a.server_id}' not found.` : `DOT1X SERVER DETAILS:
|
|
9513
|
+
|
|
9514
|
+
${result}`;
|
|
9370
9515
|
}
|
|
9371
|
-
|
|
9372
|
-
|
|
9373
|
-
|
|
9374
|
-
|
|
9375
|
-
|
|
9376
|
-
|
|
9377
|
-
|
|
9378
|
-
|
|
9379
|
-
|
|
9380
|
-
|
|
9381
|
-
|
|
9382
|
-
|
|
9383
|
-
|
|
9384
|
-
|
|
9385
|
-
|
|
9386
|
-
|
|
9387
|
-
|
|
9388
|
-
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
|
|
9393
|
-
|
|
9394
|
-
|
|
9395
|
-
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
|
|
9399
|
-
|
|
9400
|
-
|
|
9401
|
-
|
|
9402
|
-
|
|
9403
|
-
|
|
9404
|
-
}
|
|
9405
|
-
|
|
9406
|
-
|
|
9407
|
-
|
|
9408
|
-
}
|
|
9409
|
-
|
|
9410
|
-
|
|
9411
|
-
|
|
9412
|
-
|
|
9413
|
-
|
|
9414
|
-
|
|
9415
|
-
|
|
9416
|
-
|
|
9417
|
-
|
|
9418
|
-
|
|
9419
|
-
|
|
9420
|
-
|
|
9421
|
-
|
|
9422
|
-
|
|
9423
|
-
|
|
9424
|
-
|
|
9425
|
-
|
|
9426
|
-
|
|
9427
|
-
|
|
9428
|
-
|
|
9429
|
-
|
|
9430
|
-
|
|
9431
|
-
|
|
9432
|
-
|
|
9433
|
-
|
|
9434
|
-
|
|
9435
|
-
|
|
9436
|
-
|
|
9437
|
-
|
|
9438
|
-
|
|
9439
|
-
|
|
9440
|
-
|
|
9441
|
-
|
|
9442
|
-
|
|
9443
|
-
|
|
9444
|
-
|
|
9445
|
-
|
|
9446
|
-
|
|
9447
|
-
|
|
9448
|
-
|
|
9449
|
-
|
|
9450
|
-
|
|
9451
|
-
|
|
9452
|
-
|
|
9453
|
-
|
|
9454
|
-
|
|
9455
|
-
|
|
9456
|
-
|
|
9457
|
-
|
|
9458
|
-
|
|
9459
|
-
|
|
9460
|
-
|
|
9461
|
-
|
|
9462
|
-
|
|
9516
|
+
}),
|
|
9517
|
+
defineTool({
|
|
9518
|
+
name: "update_dot1x_server",
|
|
9519
|
+
title: "Update 802.1X Server (Authenticator) Entry",
|
|
9520
|
+
annotations: WRITE_IDEMPOTENT,
|
|
9521
|
+
description: "Modifies an existing 802.1X authenticator entry (`/interface dot1x server set`) identified by interface name or RouterOS `.id`. " + "Pass `server_id` as a `.id` string (starts with `*`, e.g. `*1` \u2014 obtain from `list_dot1x_servers`) or as the interface name (e.g. `ether2`). " + 'Omit any optional field to leave it unchanged; pass `comment=""` to clear the comment. ' + "For creating a new entry use `add_dot1x_server`; for deletion use `remove_dot1x_server`. " + "Returns the updated entry's full detail after the change.",
|
|
9522
|
+
inputSchema: {
|
|
9523
|
+
server_id: z23.string().describe("Interface name or RouterOS '.id'"),
|
|
9524
|
+
auth_types: AuthTypes.optional(),
|
|
9525
|
+
accounting: z23.boolean().optional(),
|
|
9526
|
+
interim_update: z23.string().optional(),
|
|
9527
|
+
mac_auth_mode: MacAuthMode.optional(),
|
|
9528
|
+
guest_vlan_id: z23.string().optional(),
|
|
9529
|
+
reject_vlan_id: z23.string().optional(),
|
|
9530
|
+
server_fail_vlan_id: z23.string().optional(),
|
|
9531
|
+
reauth_timeout: z23.string().optional(),
|
|
9532
|
+
comment: z23.string().optional(),
|
|
9533
|
+
disabled: z23.boolean().optional()
|
|
9534
|
+
},
|
|
9535
|
+
async handler(a, ctx) {
|
|
9536
|
+
ctx.info(`Updating dot1x server: server_id=${a.server_id}`);
|
|
9537
|
+
const selector = a.server_id.startsWith("*") ? `.id="${a.server_id}"` : `interface="${a.server_id}"`;
|
|
9538
|
+
const base = `/interface dot1x server set [find ${selector}]`;
|
|
9539
|
+
const cmd = new Cmd(base).opt("auth-types", a.auth_types).bool("accounting", a.accounting).opt("interim-update", a.interim_update).opt("mac-auth-mode", a.mac_auth_mode).opt("guest-vlan-id", a.guest_vlan_id).opt("reject-vlan-id", a.reject_vlan_id).opt("server-fail-vlan-id", a.server_fail_vlan_id).opt("reauth-timeout", a.reauth_timeout);
|
|
9540
|
+
if (a.comment !== undefined)
|
|
9541
|
+
cmd.raw(`comment=${quoteValue(a.comment)}`);
|
|
9542
|
+
if (a.disabled !== undefined)
|
|
9543
|
+
cmd.raw(`disabled=${yesno(a.disabled)}`);
|
|
9544
|
+
const built = cmd.build();
|
|
9545
|
+
if (built === base)
|
|
9546
|
+
return "No updates specified.";
|
|
9547
|
+
const result = await executeMikrotikCommand(built, ctx);
|
|
9548
|
+
if (looksLikeError(result))
|
|
9549
|
+
return `Failed to update dot1x server: ${result}`;
|
|
9550
|
+
const details = await executeMikrotikCommand(`/interface dot1x server print detail where ${selector}`, ctx);
|
|
9551
|
+
return `Dot1x server updated successfully:
|
|
9552
|
+
|
|
9553
|
+
${details}`;
|
|
9554
|
+
}
|
|
9555
|
+
}),
|
|
9556
|
+
defineTool({
|
|
9557
|
+
name: "remove_dot1x_server",
|
|
9558
|
+
title: "Remove 802.1X Server (Authenticator) Entry",
|
|
9559
|
+
annotations: DESTRUCTIVE,
|
|
9560
|
+
description: "Permanently removes an 802.1X authenticator entry (`/interface dot1x server remove`) identified by interface name or RouterOS `.id`. " + "Pass `server_id` as a `.id` string (starts with `*`, e.g. `*1` \u2014 obtain from `list_dot1x_servers`) or as the interface name (e.g. `ether2`). " + "Verifies the entry exists via a `count-only` check before deletion \u2014 returns a not-found message if absent. " + "For creating an entry use `add_dot1x_server`; for non-destructive disabling use `update_dot1x_server` with `disabled=true`.",
|
|
9561
|
+
inputSchema: {
|
|
9562
|
+
server_id: z23.string().describe("Interface name or RouterOS '.id'")
|
|
9563
|
+
},
|
|
9564
|
+
async handler(a, ctx) {
|
|
9565
|
+
ctx.info(`Removing dot1x server: server_id=${a.server_id}`);
|
|
9566
|
+
const selector = a.server_id.startsWith("*") ? `.id="${a.server_id}"` : `interface="${a.server_id}"`;
|
|
9567
|
+
const count = await executeMikrotikCommand(`/interface dot1x server print count-only where ${selector}`, ctx);
|
|
9568
|
+
if (count.trim() === "0")
|
|
9569
|
+
return `Dot1x server '${a.server_id}' not found.`;
|
|
9570
|
+
const result = await executeMikrotikCommand(`/interface dot1x server remove [find ${selector}]`, ctx);
|
|
9571
|
+
if (looksLikeError(result))
|
|
9572
|
+
return `Failed to remove dot1x server: ${result}`;
|
|
9573
|
+
return `Dot1x server '${a.server_id}' removed successfully.`;
|
|
9574
|
+
}
|
|
9575
|
+
})
|
|
9576
|
+
];
|
|
9577
|
+
|
|
9578
|
+
// src/tools/dot1x-client.ts
|
|
9579
|
+
import { z as z24 } from "zod";
|
|
9580
|
+
var dot1xClientTools = [
|
|
9581
|
+
defineTool({
|
|
9582
|
+
name: "add_dot1x_client",
|
|
9583
|
+
title: "Add 802.1X Supplicant Client",
|
|
9584
|
+
annotations: WRITE,
|
|
9585
|
+
description: "Adds an 802.1X supplicant client entry on a specific interface (`/interface dot1x client add`) " + "so the router authenticates itself to an upstream 802.1X authenticator (e.g. a managed switch " + "or wireless AP). Use this when the MikroTik device is the *supplicant* (client) side of 802.1X " + "\u2014 not the server/authenticator side. " + "`eap_methods` is a comma-separated list, e.g. `'eap-tls'`, `'eap-peap'`, `'eap-mschapv2'`, " + "`'eap-ttls'`; `certificate` is required for eap-tls; `identity`/`password` are required for " + "password-based EAP methods. " + "Returns the created entry's full detail (including its `.id`) which is used by " + "update_dot1x_client, get_dot1x_client, and remove_dot1x_client.",
|
|
9586
|
+
inputSchema: {
|
|
9587
|
+
interface: z24.string(),
|
|
9588
|
+
eap_methods: z24.string().describe("Comma-separated EAP methods, e.g. 'eap-tls' or 'eap-peap,eap-mschapv2'"),
|
|
9589
|
+
identity: z24.string().optional().describe("EAP identity (username)"),
|
|
9590
|
+
anonymous_identity: z24.string().optional(),
|
|
9591
|
+
certificate: z24.string().optional().describe("Client certificate name (required for eap-tls)"),
|
|
9592
|
+
password: z24.string().optional().describe("EAP password (password methods)"),
|
|
9593
|
+
comment: z24.string().optional(),
|
|
9594
|
+
disabled: z24.boolean().default(false)
|
|
9595
|
+
},
|
|
9596
|
+
async handler(a, ctx) {
|
|
9597
|
+
ctx.info(`Adding dot1x client: interface=${a.interface}`);
|
|
9598
|
+
const cmd = new Cmd("/interface dot1x client add").set("interface", a.interface).set("eap-methods", a.eap_methods).opt("identity", a.identity).opt("anonymous-identity", a.anonymous_identity).opt("certificate", a.certificate).opt("password", a.password).opt("comment", a.comment).flag("disabled", a.disabled).build();
|
|
9599
|
+
const result = await executeMikrotikCommand(cmd, ctx);
|
|
9600
|
+
if (looksLikeError(result))
|
|
9601
|
+
return `Failed to add dot1x client: ${result}`;
|
|
9602
|
+
const details = await executeMikrotikCommand(`/interface dot1x client print detail where interface="${a.interface}"`, ctx);
|
|
9603
|
+
return details.trim() ? `Dot1x client added successfully:
|
|
9604
|
+
|
|
9605
|
+
${details}` : "Dot1x client addition completed but unable to verify.";
|
|
9606
|
+
}
|
|
9607
|
+
}),
|
|
9608
|
+
defineTool({
|
|
9609
|
+
name: "list_dot1x_clients",
|
|
9610
|
+
title: "List 802.1X Supplicant Clients",
|
|
9611
|
+
annotations: READ,
|
|
9612
|
+
description: "Lists all 802.1X supplicant client entries configured on the device (`/interface dot1x client print`). " + "Optionally filters by interface name (`interface_filter`), authentication status string " + "(`status_filter`, e.g. `'authenticated'`, `'authenticating'`), or disabled state (`disabled_only`). " + "Returns a table of all matching supplicant entries with their interface, EAP method, and status; " + "for full detail on a single entry use get_dot1x_client.",
|
|
9613
|
+
inputSchema: {
|
|
9614
|
+
interface_filter: z24.string().optional(),
|
|
9615
|
+
status_filter: z24.string().optional().describe("Match status, e.g. 'authenticated', 'authenticating'"),
|
|
9616
|
+
disabled_only: z24.boolean().default(false)
|
|
9617
|
+
},
|
|
9618
|
+
async handler(a, ctx) {
|
|
9619
|
+
ctx.info("Listing dot1x clients");
|
|
9620
|
+
const filters = [];
|
|
9621
|
+
if (a.interface_filter)
|
|
9622
|
+
filters.push(`interface="${a.interface_filter}"`);
|
|
9623
|
+
if (a.status_filter)
|
|
9624
|
+
filters.push(`status~"${a.status_filter}"`);
|
|
9625
|
+
if (a.disabled_only)
|
|
9626
|
+
filters.push("disabled=yes");
|
|
9627
|
+
const result = await executeMikrotikCommand(`/interface dot1x client print${whereClause(filters)}`, ctx);
|
|
9628
|
+
return isEmpty(result) ? "No dot1x clients found matching the criteria." : `DOT1X CLIENTS:
|
|
9629
|
+
|
|
9630
|
+
${result}`;
|
|
9463
9631
|
}
|
|
9464
|
-
|
|
9465
|
-
|
|
9466
|
-
|
|
9467
|
-
|
|
9468
|
-
|
|
9469
|
-
|
|
9470
|
-
|
|
9471
|
-
|
|
9472
|
-
|
|
9473
|
-
|
|
9474
|
-
|
|
9475
|
-
|
|
9632
|
+
}),
|
|
9633
|
+
defineTool({
|
|
9634
|
+
name: "get_dot1x_client",
|
|
9635
|
+
title: "Get 802.1X Supplicant Client Detail",
|
|
9636
|
+
annotations: READ,
|
|
9637
|
+
description: "Fetches full detail for a single 802.1X supplicant client entry (`/interface dot1x client print detail`). " + "Accepts either an interface name (e.g. `'ether3'`) or a RouterOS `.id` string (e.g. `'*1'`) from " + "list_dot1x_clients as `client_id` \u2014 tries `.id` lookup first, then falls back to interface name. " + "Returns the complete supplicant configuration including EAP method, identity, certificate, and current " + "authentication status. For a bulk view of all entries use list_dot1x_clients.",
|
|
9638
|
+
inputSchema: {
|
|
9639
|
+
client_id: z24.string().describe("Interface name (e.g. 'ether3') or RouterOS '.id'")
|
|
9640
|
+
},
|
|
9641
|
+
async handler(a, ctx) {
|
|
9642
|
+
ctx.info(`Getting dot1x client: client_id=${a.client_id}`);
|
|
9643
|
+
let result = await executeMikrotikCommand(`/interface dot1x client print detail where .id="${a.client_id}"`, ctx);
|
|
9644
|
+
if (isEmpty(result)) {
|
|
9645
|
+
result = await executeMikrotikCommand(`/interface dot1x client print detail where interface="${a.client_id}"`, ctx);
|
|
9476
9646
|
}
|
|
9647
|
+
return isEmpty(result) ? `Dot1x client '${a.client_id}' not found.` : `DOT1X CLIENT DETAILS:
|
|
9648
|
+
|
|
9649
|
+
${result}`;
|
|
9477
9650
|
}
|
|
9478
|
-
}
|
|
9479
|
-
|
|
9480
|
-
|
|
9481
|
-
|
|
9482
|
-
|
|
9483
|
-
|
|
9484
|
-
|
|
9485
|
-
|
|
9486
|
-
|
|
9487
|
-
|
|
9488
|
-
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
|
|
9492
|
-
|
|
9493
|
-
|
|
9494
|
-
|
|
9495
|
-
|
|
9496
|
-
|
|
9497
|
-
|
|
9498
|
-
|
|
9499
|
-
|
|
9500
|
-
|
|
9501
|
-
|
|
9502
|
-
|
|
9503
|
-
|
|
9504
|
-
|
|
9505
|
-
|
|
9506
|
-
|
|
9507
|
-
|
|
9508
|
-
|
|
9509
|
-
|
|
9510
|
-
|
|
9511
|
-
|
|
9512
|
-
|
|
9651
|
+
}),
|
|
9652
|
+
defineTool({
|
|
9653
|
+
name: "update_dot1x_client",
|
|
9654
|
+
title: "Update 802.1X Supplicant Client",
|
|
9655
|
+
annotations: WRITE_IDEMPOTENT,
|
|
9656
|
+
description: "Modifies an existing 802.1X supplicant client entry (`/interface dot1x client set`) identified by " + "interface name or RouterOS `.id` from list_dot1x_clients. `client_id` accepts an interface name " + "(e.g. `'ether3'`) or a `.id` string starting with `'*'`. Only supplied fields are changed; " + 'pass `comment=""` to clear the comment. ' + "Returns the updated entry's full detail after the change. To add a new supplicant entry use add_dot1x_client; " + "to delete one use remove_dot1x_client.",
|
|
9657
|
+
inputSchema: {
|
|
9658
|
+
client_id: z24.string().describe("Interface name or RouterOS '.id'"),
|
|
9659
|
+
eap_methods: z24.string().optional(),
|
|
9660
|
+
identity: z24.string().optional(),
|
|
9661
|
+
anonymous_identity: z24.string().optional(),
|
|
9662
|
+
certificate: z24.string().optional(),
|
|
9663
|
+
password: z24.string().optional(),
|
|
9664
|
+
comment: z24.string().optional(),
|
|
9665
|
+
disabled: z24.boolean().optional()
|
|
9666
|
+
},
|
|
9667
|
+
async handler(a, ctx) {
|
|
9668
|
+
ctx.info(`Updating dot1x client: client_id=${a.client_id}`);
|
|
9669
|
+
const selector = a.client_id.startsWith("*") ? `.id="${a.client_id}"` : `interface="${a.client_id}"`;
|
|
9670
|
+
const base = `/interface dot1x client set [find ${selector}]`;
|
|
9671
|
+
const cmd = new Cmd(base).opt("eap-methods", a.eap_methods).opt("identity", a.identity).opt("anonymous-identity", a.anonymous_identity).opt("certificate", a.certificate).opt("password", a.password);
|
|
9672
|
+
if (a.comment !== undefined)
|
|
9673
|
+
cmd.raw(`comment=${quoteValue(a.comment)}`);
|
|
9674
|
+
if (a.disabled !== undefined)
|
|
9675
|
+
cmd.raw(`disabled=${yesno(a.disabled)}`);
|
|
9676
|
+
const built = cmd.build();
|
|
9677
|
+
if (built === base)
|
|
9678
|
+
return "No updates specified.";
|
|
9679
|
+
const result = await executeMikrotikCommand(built, ctx);
|
|
9680
|
+
if (looksLikeError(result))
|
|
9681
|
+
return `Failed to update dot1x client: ${result}`;
|
|
9682
|
+
const details = await executeMikrotikCommand(`/interface dot1x client print detail where ${selector}`, ctx);
|
|
9683
|
+
return `Dot1x client updated successfully:
|
|
9684
|
+
|
|
9685
|
+
${details}`;
|
|
9513
9686
|
}
|
|
9514
|
-
}
|
|
9515
|
-
|
|
9516
|
-
|
|
9517
|
-
|
|
9518
|
-
|
|
9519
|
-
|
|
9520
|
-
|
|
9521
|
-
|
|
9522
|
-
|
|
9523
|
-
|
|
9524
|
-
|
|
9525
|
-
|
|
9526
|
-
|
|
9527
|
-
|
|
9528
|
-
|
|
9529
|
-
|
|
9530
|
-
|
|
9687
|
+
}),
|
|
9688
|
+
defineTool({
|
|
9689
|
+
name: "remove_dot1x_client",
|
|
9690
|
+
title: "Remove 802.1X Supplicant Client",
|
|
9691
|
+
annotations: DESTRUCTIVE,
|
|
9692
|
+
description: "Removes an 802.1X supplicant client entry (`/interface dot1x client remove`) identified by interface " + "name or RouterOS `.id` from list_dot1x_clients. `client_id` accepts an interface name (e.g. `'ether3'`) " + "or a `.id` string starting with `'*'`. Verifies the entry exists with a count-only check before deletion " + "and returns an error if not found. Permanently stops 802.1X authentication on that interface; " + "to temporarily halt authentication without deleting the entry use update_dot1x_client with `disabled=true`.",
|
|
9693
|
+
inputSchema: {
|
|
9694
|
+
client_id: z24.string().describe("Interface name or RouterOS '.id'")
|
|
9695
|
+
},
|
|
9696
|
+
async handler(a, ctx) {
|
|
9697
|
+
ctx.info(`Removing dot1x client: client_id=${a.client_id}`);
|
|
9698
|
+
const selector = a.client_id.startsWith("*") ? `.id="${a.client_id}"` : `interface="${a.client_id}"`;
|
|
9699
|
+
const count = await executeMikrotikCommand(`/interface dot1x client print count-only where ${selector}`, ctx);
|
|
9700
|
+
if (count.trim() === "0")
|
|
9701
|
+
return `Dot1x client '${a.client_id}' not found.`;
|
|
9702
|
+
const result = await executeMikrotikCommand(`/interface dot1x client remove [find ${selector}]`, ctx);
|
|
9703
|
+
if (looksLikeError(result))
|
|
9704
|
+
return `Failed to remove dot1x client: ${result}`;
|
|
9705
|
+
return `Dot1x client '${a.client_id}' removed successfully.`;
|
|
9531
9706
|
}
|
|
9532
|
-
}
|
|
9533
|
-
|
|
9534
|
-
}
|
|
9535
|
-
var WEIGHT2 = { high: 20, medium: 8, low: 2 };
|
|
9536
|
-
function grade2(score) {
|
|
9537
|
-
if (score === 0)
|
|
9538
|
-
return "clean";
|
|
9539
|
-
if (score < 15)
|
|
9540
|
-
return "good";
|
|
9541
|
-
if (score < 40)
|
|
9542
|
-
return "fair";
|
|
9543
|
-
if (score < 75)
|
|
9544
|
-
return "poor";
|
|
9545
|
-
return "critical";
|
|
9546
|
-
}
|
|
9547
|
-
function auditFirewall(input) {
|
|
9548
|
-
_ifaceLists = input.interfaceLists;
|
|
9549
|
-
const findings = [];
|
|
9550
|
-
if (input.filter)
|
|
9551
|
-
findings.push(...auditFilter(input.filter));
|
|
9552
|
-
if (input.nat)
|
|
9553
|
-
findings.push(...auditTransform(input.nat, "nat"));
|
|
9554
|
-
if (input.mangle)
|
|
9555
|
-
findings.push(...auditTransform(input.mangle, "mangle"));
|
|
9556
|
-
const sevRank = { high: 0, medium: 1, low: 2 };
|
|
9557
|
-
findings.sort((a, b) => sevRank[a.severity] - sevRank[b.severity] || a.table.localeCompare(b.table) || (a.ruleIndex ?? -1) - (b.ruleIndex ?? -1));
|
|
9558
|
-
const counts = { high: 0, medium: 0, low: 0, total: findings.length };
|
|
9559
|
-
let raw = 0;
|
|
9560
|
-
for (const f of findings) {
|
|
9561
|
-
counts[f.severity]++;
|
|
9562
|
-
raw += WEIGHT2[f.severity];
|
|
9563
|
-
}
|
|
9564
|
-
const riskScore = Math.min(100, raw);
|
|
9565
|
-
const ruleCount = (input.filter?.length ?? 0) + (input.nat?.length ?? 0) + (input.mangle?.length ?? 0);
|
|
9566
|
-
return { riskScore, grade: grade2(riskScore), counts, ruleCount, findings };
|
|
9567
|
-
}
|
|
9568
|
-
function renderReport(report, device) {
|
|
9569
|
-
const head = `FIREWALL AUDIT \u2014 ${device}
|
|
9570
|
-
|
|
9571
|
-
` + `Risk score: ${report.riskScore}/100 (${report.grade})
|
|
9572
|
-
` + `${report.ruleCount} rule(s) analysed \xB7 ${report.counts.high} high, ${report.counts.medium} medium, ${report.counts.low} low
|
|
9573
|
-
`;
|
|
9574
|
-
if (report.findings.length === 0) {
|
|
9575
|
-
return `${head}
|
|
9576
|
-
No issues found \u2014 the ruleset looks clean. \u2713`;
|
|
9577
|
-
}
|
|
9578
|
-
const body = report.findings.map((f, i) => {
|
|
9579
|
-
const tag = f.severity.toUpperCase().padEnd(6);
|
|
9580
|
-
return `${i + 1}. [${tag}] ${f.title} (${f.table}/${f.chain})
|
|
9581
|
-
${f.detail}
|
|
9582
|
-
\u2192 ${f.suggestion}`;
|
|
9583
|
-
}).join(`
|
|
9584
|
-
|
|
9585
|
-
`);
|
|
9586
|
-
return `${head}
|
|
9587
|
-
${body}`;
|
|
9588
|
-
}
|
|
9707
|
+
})
|
|
9708
|
+
];
|
|
9589
9709
|
|
|
9590
9710
|
// src/tools/firewall-audit.ts
|
|
9711
|
+
import { z as z25 } from "zod";
|
|
9591
9712
|
async function fetchRules(path, ctx) {
|
|
9592
9713
|
const out = await executeMikrotikCommand(`${path} print detail`, ctx);
|
|
9593
9714
|
if (looksLikeError(out) || isEmpty(out))
|
|
@@ -11634,21 +11755,6 @@ function planPortScanDetection(state, args) {
|
|
|
11634
11755
|
}
|
|
11635
11756
|
|
|
11636
11757
|
// src/tools/port-scan-detection.ts
|
|
11637
|
-
async function fetchChainRules(chain, ctx) {
|
|
11638
|
-
const rows = await fetchRows(`/ip firewall filter print detail where chain=${chain}`, ctx);
|
|
11639
|
-
return rulesFromRows(rows);
|
|
11640
|
-
}
|
|
11641
|
-
async function addressListCount(list, ctx) {
|
|
11642
|
-
const raw = await executeMikrotikCommand(`/ip firewall address-list print count-only where list=${JSON.stringify(list)}`, ctx);
|
|
11643
|
-
const n = Number.parseInt(raw.trim(), 10);
|
|
11644
|
-
return Number.isFinite(n) ? n : 0;
|
|
11645
|
-
}
|
|
11646
|
-
async function inputChainIds(ctx) {
|
|
11647
|
-
const raw = await executeMikrotikCommand(":foreach i in=[/ip firewall filter find chain=input] do={:put $i}", ctx);
|
|
11648
|
-
if (isEmpty(raw))
|
|
11649
|
-
return [];
|
|
11650
|
-
return raw.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => /^\*[0-9A-Fa-f]+$/.test(l));
|
|
11651
|
-
}
|
|
11652
11758
|
var portScanDetectionTools = [
|
|
11653
11759
|
defineTool({
|
|
11654
11760
|
name: "list_port_scan_detection_signatures",
|
|
@@ -11658,7 +11764,7 @@ var portScanDetectionTools = [
|
|
|
11658
11764
|
async handler(_a, ctx) {
|
|
11659
11765
|
let present = null;
|
|
11660
11766
|
if (ctx.device !== undefined) {
|
|
11661
|
-
const detect = await
|
|
11767
|
+
const detect = await fetchFilterChainRules(DETECT_CHAIN, ctx);
|
|
11662
11768
|
present = new Set(PORT_SCAN_SIGNATURES.filter((s) => signaturePresent(detect, s)).map((s) => s.id));
|
|
11663
11769
|
}
|
|
11664
11770
|
const lines = [
|
|
@@ -11681,7 +11787,7 @@ var portScanDetectionTools = [
|
|
|
11681
11787
|
name: "add_port_scan_detection_rules",
|
|
11682
11788
|
title: "Add Port-Scan Detection Rules",
|
|
11683
11789
|
annotations: DANGEROUS,
|
|
11684
|
-
description: "Installs the user-selected port-scan detection signatures into a dedicated `detect-portscan` " + "sub-chain, gated by a single input jump that EXCLUDES a trusted address-list (so trusted " + "sources are never tagged). These rules only add the source to an address list \u2014 they NEVER " + "drop or block. NEVER call this with a guessed or default set of rule_types: it must only be " + "called with signature IDs the user explicitly chose after seeing " + "list_port_scan_detection_signatures \u2014 there is no select-all. Requires `trusted_list_name` " + "(must already exist and be non-empty on the device) and the human acknowledgement " + "`confirmed_trusted_list_includes_my_ip=true`. Captures a config snapshot
|
|
11790
|
+
description: "Installs the user-selected port-scan detection signatures into a dedicated `detect-portscan` " + "sub-chain, gated by a single input jump that EXCLUDES a trusted address-list (so trusted " + "sources are never tagged). These rules only add the source to an address list \u2014 they NEVER " + "drop or block. NEVER call this with a guessed or default set of rule_types: it must only be " + "called with signature IDs the user explicitly chose after seeing " + "list_port_scan_detection_signatures \u2014 there is no select-all. Requires `trusted_list_name` " + "(must already exist and be non-empty on the device) and the human acknowledgement " + "`confirmed_trusted_list_includes_my_ip=true`. Captures a config snapshot, then applies every " + "write inside Safe Mode (auto-revert on session drop) \u2014 or, if Safe Mode is unavailable or goes " + "silent on the device (a known flaky case on some RouterOS SSH builds), applies the writes " + "DIRECTLY instead of aborting, since these rules only tag and cannot lock you out (the snapshot " + "is the rollback point). Idempotent \u2014 a second identical run adds nothing. Enforcement/blocking " + "of the tagged list is intentionally out of scope.",
|
|
11685
11791
|
inputSchema: {
|
|
11686
11792
|
rule_types: z28.array(z28.enum(PORT_SCAN_SIGNATURE_IDS)).min(1).describe("REQUIRED, non-empty. The specific signature IDs the user explicitly chose after seeing " + "the catalog. No default, no select-all \u2014 unknown values are rejected."),
|
|
11687
11793
|
trusted_list_name: z28.string().min(1).describe("REQUIRED. The management/trusted address-list on THIS device (no default). Must already " + "exist and contain at least one entry, or the call is refused."),
|
|
@@ -11694,8 +11800,8 @@ var portScanDetectionTools = [
|
|
|
11694
11800
|
const device = resolveDeviceName(ctx.device);
|
|
11695
11801
|
ctx.info(`[${device}] add_port_scan_detection_rules: ${a.rule_types.join(",")}`);
|
|
11696
11802
|
const [inputRules, detectChainRules, trustListCount] = await Promise.all([
|
|
11697
|
-
|
|
11698
|
-
|
|
11803
|
+
fetchFilterChainRules("input", ctx),
|
|
11804
|
+
fetchFilterChainRules(DETECT_CHAIN, ctx),
|
|
11699
11805
|
addressListCount(a.trusted_list_name, ctx)
|
|
11700
11806
|
]);
|
|
11701
11807
|
const state = {
|
|
@@ -11720,61 +11826,30 @@ ${plan.error}`;
|
|
|
11720
11826
|
if (!plan.jump.present) {
|
|
11721
11827
|
let placeBeforeId;
|
|
11722
11828
|
if (plan.jump.placeBeforeIndex !== null) {
|
|
11723
|
-
const ids = await
|
|
11829
|
+
const ids = await filterChainRuleIds("input", ctx);
|
|
11724
11830
|
placeBeforeId = ids[plan.jump.placeBeforeIndex];
|
|
11725
11831
|
}
|
|
11726
11832
|
writeCommands.push(buildJumpCommand(a.trusted_list_name, placeBeforeId));
|
|
11727
11833
|
}
|
|
11728
|
-
const result = await
|
|
11834
|
+
const result = await applyWritesSafely(ctx, device, writeCommands, {
|
|
11835
|
+
allowDirectFallback: true
|
|
11836
|
+
});
|
|
11729
11837
|
return renderResult(a, plan, snapshotId, result, ctx);
|
|
11730
11838
|
}
|
|
11731
11839
|
})
|
|
11732
11840
|
];
|
|
11733
|
-
async function applyWrites(ctx, deviceName, commands) {
|
|
11734
|
-
if (commands.length === 0)
|
|
11735
|
-
return { applied: 0, safeMode: "not used (nothing to write)", committed: true };
|
|
11736
|
-
const useSafe = !getDevice(deviceName).mac;
|
|
11737
|
-
const mgr = getSafeModeManager(deviceName);
|
|
11738
|
-
if (useSafe) {
|
|
11739
|
-
const en = await mgr.enable();
|
|
11740
|
-
if (en.startsWith("Error"))
|
|
11741
|
-
return { applied: 0, safeMode: `failed to enable: ${en}`, committed: false };
|
|
11742
|
-
}
|
|
11743
|
-
let applied = 0;
|
|
11744
|
-
for (const cmd of commands) {
|
|
11745
|
-
const out = useSafe ? await mgr.execute(cmd).catch((e) => `error: ${String(e)}`) : await executeMikrotikCommand(cmd, ctx);
|
|
11746
|
-
if (looksLikeError(out) || out.startsWith("error:")) {
|
|
11747
|
-
if (useSafe)
|
|
11748
|
-
await mgr.rollback();
|
|
11749
|
-
return {
|
|
11750
|
-
applied,
|
|
11751
|
-
safeMode: useSafe ? "rolled back (a write failed \u2014 no changes kept)" : "not used",
|
|
11752
|
-
committed: false,
|
|
11753
|
-
error: out.trim().split(`
|
|
11754
|
-
`)[0]
|
|
11755
|
-
};
|
|
11756
|
-
}
|
|
11757
|
-
applied++;
|
|
11758
|
-
}
|
|
11759
|
-
if (useSafe) {
|
|
11760
|
-
const c = await mgr.commit();
|
|
11761
|
-
return {
|
|
11762
|
-
applied,
|
|
11763
|
-
safeMode: c.ok ? "committed" : `commit FAILED \u2014 changes revert: ${c.message}`,
|
|
11764
|
-
committed: c.ok
|
|
11765
|
-
};
|
|
11766
|
-
}
|
|
11767
|
-
return { applied, safeMode: "not used (MAC-Telnet device \u2014 no Safe Mode)", committed: true };
|
|
11768
|
-
}
|
|
11769
11841
|
async function renderResult(a, plan, snapshotId, outcome, ctx) {
|
|
11770
11842
|
const lines = [];
|
|
11771
11843
|
lines.push(`PORT-SCAN DETECTION \u2014 snapshot=${snapshotId} safe-mode=${outcome.safeMode}`);
|
|
11772
11844
|
if (outcome.error) {
|
|
11773
|
-
lines.push(`FAILED after ${outcome.applied} write(s): ${outcome.error}`);
|
|
11774
|
-
lines.push(
|
|
11845
|
+
lines.push(`FAILED after ${outcome.applied}/${outcome.total} write(s): ${outcome.error}`);
|
|
11846
|
+
lines.push(outcome.fellBack || outcome.applied > 0 ? `Partial state may be applied. The rules are idempotent \u2014 fix the cause and RE-RUN to finish, ` + `or roll back with: diff_config_snapshots from=${snapshotId} to=live (then restore the snapshot).` : "No changes were kept \u2014 fix the cause and re-run.");
|
|
11775
11847
|
return lines.join(`
|
|
11776
11848
|
`);
|
|
11777
11849
|
}
|
|
11850
|
+
if (outcome.fellBack) {
|
|
11851
|
+
lines.push("NOTE: Safe Mode was not usable on this device, so the writes were applied directly. These " + "rules cannot lock you out (they only tag; the jump excludes the trusted list), and the " + `snapshot above is your rollback point (diff_config_snapshots from=${snapshotId} to=live).`);
|
|
11852
|
+
}
|
|
11778
11853
|
const created = plan.signatures.filter((s) => s.status === "create");
|
|
11779
11854
|
const existing = plan.signatures.filter((s) => s.status === "already_present");
|
|
11780
11855
|
lines.push("");
|
|
@@ -12008,9 +12083,9 @@ var firewallFilterTools = [
|
|
|
12008
12083
|
name: "create_filter_rule",
|
|
12009
12084
|
title: "Create Firewall Filter Rule",
|
|
12010
12085
|
annotations: WRITE,
|
|
12011
|
-
description: "Creates an IPv4 firewall FILTER rule (`/ip firewall filter`) \u2014 the accept/drop/reject " + "decision table for traffic TO the router (chain=input), THROUGH it (forward) or FROM it " + "(output). Use this to allow or block traffic. For address translation use the NAT tools, " + "for packet marking use mangle, for pre-connection-tracking drops use raw, and for IPv6 use " + "create_ipv6_filter_rule. Rule order matters (first match wins) \u2014 use place_before or " + "move_filter_rule to position it. Returns the created rule's detail including its `.id`. " + 'connection_state: comma-separated e.g. "established,related,new,invalid". ' + 'limit: RouterOS rate/burst string e.g. "10,5:packet" or "10/1s:packet". ' + 'tcp_flags: RouterOS flag expression e.g. "syn,!ack". ' + 'place_before: rule number or ID (*N) to insert before e.g. "0" or "*3".',
|
|
12086
|
+
description: "Creates an IPv4 firewall FILTER rule (`/ip firewall filter`) \u2014 the accept/drop/reject " + "decision table for traffic TO the router (chain=input), THROUGH it (forward) or FROM it " + "(output). Use this to allow or block traffic. For address translation use the NAT tools, " + "for packet marking use mangle, for pre-connection-tracking drops use raw, and for IPv6 use " + "create_ipv6_filter_rule. `chain` also accepts a CUSTOM sub-chain name (e.g. detect-portscan) " + "reached by an action=jump rule, and `action` supports add-src-to-address-list / " + "add-dst-to-address-list (with `address_list` + `address_list_timeout`) for tagging/detection " + "rules. Rule order matters (first match wins) \u2014 use place_before or " + "move_filter_rule to position it. Returns the created rule's detail including its `.id`. " + 'connection_state: comma-separated e.g. "established,related,new,invalid". ' + 'limit: RouterOS rate/burst string e.g. "10,5:packet" or "10/1s:packet". ' + 'tcp_flags: RouterOS flag expression e.g. "syn,!ack". ' + 'place_before: rule number or ID (*N) to insert before e.g. "0" or "*3".',
|
|
12012
12087
|
inputSchema: {
|
|
12013
|
-
chain: z30.
|
|
12088
|
+
chain: z30.string().describe("The built-in `input`, `forward`, or `output` chain \u2014 OR a custom sub-chain name " + "(e.g. `detect-portscan`). A custom chain runs only when a rule with action=jump and " + "jump-target=<that chain> sends traffic into it; use this to build tagging/detection sub-chains."),
|
|
12014
12089
|
action: z30.enum([
|
|
12015
12090
|
"accept",
|
|
12016
12091
|
"drop",
|
|
@@ -12020,7 +12095,9 @@ var firewallFilterTools = [
|
|
|
12020
12095
|
"passthrough",
|
|
12021
12096
|
"return",
|
|
12022
12097
|
"tarpit",
|
|
12023
|
-
"fasttrack-connection"
|
|
12098
|
+
"fasttrack-connection",
|
|
12099
|
+
"add-src-to-address-list",
|
|
12100
|
+
"add-dst-to-address-list"
|
|
12024
12101
|
]),
|
|
12025
12102
|
src_address: z30.string().optional(),
|
|
12026
12103
|
dst_address: z30.string().optional(),
|
|
@@ -26774,12 +26851,12 @@ function saveFileCache(data) {
|
|
|
26774
26851
|
}
|
|
26775
26852
|
async function fetchLatestRelease() {
|
|
26776
26853
|
if (memoryCache && Date.now() - memoryCache.fetchedAt < MEMORY_CACHE_TTL) {
|
|
26777
|
-
return memoryCache.data;
|
|
26854
|
+
return withCurrentRelation(memoryCache.data);
|
|
26778
26855
|
}
|
|
26779
26856
|
const fileCached = loadFileCache();
|
|
26780
26857
|
if (fileCached) {
|
|
26781
26858
|
memoryCache = { data: fileCached, fetchedAt: Date.now() };
|
|
26782
|
-
return fileCached;
|
|
26859
|
+
return withCurrentRelation(fileCached);
|
|
26783
26860
|
}
|
|
26784
26861
|
const res = await fetch(GITHUB_API, {
|
|
26785
26862
|
headers: {
|
|
@@ -26804,7 +26881,11 @@ async function fetchLatestRelease() {
|
|
|
26804
26881
|
};
|
|
26805
26882
|
memoryCache = { data, fetchedAt: Date.now() };
|
|
26806
26883
|
saveFileCache(data);
|
|
26807
|
-
return data;
|
|
26884
|
+
return withCurrentRelation(data);
|
|
26885
|
+
}
|
|
26886
|
+
function withCurrentRelation(r) {
|
|
26887
|
+
const cmp = compareVersions(r.version, VERSION);
|
|
26888
|
+
return { ...r, currentVersion: VERSION, isNewer: cmp > 0, isAhead: cmp < 0 };
|
|
26808
26889
|
}
|
|
26809
26890
|
async function checkForUpdate() {
|
|
26810
26891
|
try {
|
|
@@ -26813,7 +26894,7 @@ async function checkForUpdate() {
|
|
|
26813
26894
|
} catch (e) {
|
|
26814
26895
|
const stale = loadFileCache(Infinity);
|
|
26815
26896
|
if (stale) {
|
|
26816
|
-
return { release: stale, checkedAt: Date.now(), fromCache: true };
|
|
26897
|
+
return { release: withCurrentRelation(stale), checkedAt: Date.now(), fromCache: true };
|
|
26817
26898
|
}
|
|
26818
26899
|
return {
|
|
26819
26900
|
release: null,
|