@usex/mikrotik-mcp 4.12.0 → 4.14.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 +1 -1
- package/dist/index.js +1 -1
- package/dist/shared/{cli-07emrpkr.js → cli-bk79xfcq.js} +1030 -953
- package/dist/shared/{cli-p6yc41tg.js → cli-cb2zp05h.js} +1 -1
- package/dist/shared/{library-pczgrhdt.js → library-k3ygd0y8.js} +1030 -953
- package/dist/shared/{library-txj981yv.js → library-sfdm0xh9.js} +1 -1
- package/dist/ui/observability.html +57 -57
- package/package.json +2 -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
|
@@ -7701,97 +7701,595 @@ ${created.join(`
|
|
|
7701
7701
|
// src/tools/container.ts
|
|
7702
7702
|
import { z as z17 } from "zod";
|
|
7703
7703
|
|
|
7704
|
-
// src/
|
|
7705
|
-
|
|
7706
|
-
|
|
7704
|
+
// src/core/firewall-audit.ts
|
|
7705
|
+
import * as ipaddr from "ipaddr.js";
|
|
7706
|
+
var NON_MATCH = new Set([
|
|
7707
|
+
"#",
|
|
7708
|
+
"flags",
|
|
7709
|
+
"comment",
|
|
7710
|
+
"action",
|
|
7711
|
+
"chain",
|
|
7712
|
+
"bytes",
|
|
7713
|
+
"packets",
|
|
7714
|
+
"log",
|
|
7715
|
+
"log-prefix",
|
|
7716
|
+
"jump-target",
|
|
7717
|
+
".id",
|
|
7718
|
+
".nextid",
|
|
7719
|
+
"disabled",
|
|
7720
|
+
"dynamic",
|
|
7721
|
+
"invalid"
|
|
7722
|
+
]);
|
|
7723
|
+
var TRANSFORM_KEYS = new Set([
|
|
7724
|
+
"to-addresses",
|
|
7725
|
+
"to-ports",
|
|
7726
|
+
"to-address",
|
|
7727
|
+
"to-port",
|
|
7728
|
+
"address-list",
|
|
7729
|
+
"new-connection-mark",
|
|
7730
|
+
"new-packet-mark",
|
|
7731
|
+
"new-routing-mark",
|
|
7732
|
+
"new-dscp",
|
|
7733
|
+
"new-mss",
|
|
7734
|
+
"new-priority",
|
|
7735
|
+
"new-ttl",
|
|
7736
|
+
"jump-target"
|
|
7737
|
+
]);
|
|
7738
|
+
var NONDETERMINISTIC = new Set([
|
|
7739
|
+
"limit",
|
|
7740
|
+
"dst-limit",
|
|
7741
|
+
"random",
|
|
7742
|
+
"nth",
|
|
7743
|
+
"psd",
|
|
7744
|
+
"connection-bytes",
|
|
7745
|
+
"connection-rate",
|
|
7746
|
+
"rate",
|
|
7747
|
+
"time",
|
|
7748
|
+
"content",
|
|
7749
|
+
"layer7-protocol",
|
|
7750
|
+
"tls-host"
|
|
7751
|
+
]);
|
|
7752
|
+
var TERMINAL = new Set(["accept", "drop", "reject", "tarpit"]);
|
|
7753
|
+
var ADDRESS_KEYS = new Set(["src-address", "dst-address"]);
|
|
7754
|
+
var CATCH_ALL_ADDR = new Set(["0.0.0.0/0", "::/0"]);
|
|
7755
|
+
function rulesFromRows(rows) {
|
|
7756
|
+
return rows.map((r, i) => {
|
|
7757
|
+
const flags = r.flags ?? "";
|
|
7758
|
+
const match = {};
|
|
7759
|
+
const transform = {};
|
|
7760
|
+
for (const [k, v] of Object.entries(r)) {
|
|
7761
|
+
if (!v || NON_MATCH.has(k))
|
|
7762
|
+
continue;
|
|
7763
|
+
if (TRANSFORM_KEYS.has(k))
|
|
7764
|
+
transform[k] = v;
|
|
7765
|
+
else
|
|
7766
|
+
match[k] = v;
|
|
7767
|
+
}
|
|
7768
|
+
const num = (s) => {
|
|
7769
|
+
if (s == null)
|
|
7770
|
+
return;
|
|
7771
|
+
const n = Number(s.replace(/\s/g, ""));
|
|
7772
|
+
return Number.isFinite(n) ? n : undefined;
|
|
7773
|
+
};
|
|
7774
|
+
return {
|
|
7775
|
+
index: r["#"] != null && /^\d+$/.test(r["#"]) ? Number(r["#"]) : i,
|
|
7776
|
+
chain: r.chain ?? "?",
|
|
7777
|
+
action: r.action ?? "?",
|
|
7778
|
+
disabled: flags.includes("X"),
|
|
7779
|
+
dynamic: flags.includes("D"),
|
|
7780
|
+
comment: r.comment,
|
|
7781
|
+
packets: num(r.packets),
|
|
7782
|
+
bytes: num(r.bytes),
|
|
7783
|
+
match,
|
|
7784
|
+
transform,
|
|
7785
|
+
raw: r
|
|
7786
|
+
};
|
|
7787
|
+
});
|
|
7707
7788
|
}
|
|
7708
|
-
function
|
|
7709
|
-
|
|
7789
|
+
function toCidr(value) {
|
|
7790
|
+
try {
|
|
7791
|
+
if (value.includes("/"))
|
|
7792
|
+
return ipaddr.parseCIDR(value);
|
|
7793
|
+
const addr = ipaddr.parse(value);
|
|
7794
|
+
return [addr, addr.kind() === "ipv6" ? 128 : 32];
|
|
7795
|
+
} catch {
|
|
7796
|
+
return null;
|
|
7797
|
+
}
|
|
7710
7798
|
}
|
|
7711
|
-
function
|
|
7712
|
-
const
|
|
7713
|
-
|
|
7799
|
+
function cidrContains(a, b) {
|
|
7800
|
+
const A = toCidr(a);
|
|
7801
|
+
const B = toCidr(b);
|
|
7802
|
+
if (!A || !B)
|
|
7714
7803
|
return false;
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
if (
|
|
7718
|
-
return
|
|
7719
|
-
if (
|
|
7720
|
-
return
|
|
7721
|
-
|
|
7804
|
+
const [aAddr, aBits] = A;
|
|
7805
|
+
const [bAddr, bBits] = B;
|
|
7806
|
+
if (aAddr.kind() !== bAddr.kind())
|
|
7807
|
+
return false;
|
|
7808
|
+
if (aBits > bBits)
|
|
7809
|
+
return false;
|
|
7810
|
+
try {
|
|
7811
|
+
return bAddr.match(aAddr, aBits);
|
|
7812
|
+
} catch {
|
|
7813
|
+
return false;
|
|
7814
|
+
}
|
|
7815
|
+
}
|
|
7816
|
+
function covers(key, aVal, bVal) {
|
|
7817
|
+
if (aVal === bVal)
|
|
7722
7818
|
return true;
|
|
7819
|
+
if (ADDRESS_KEYS.has(key))
|
|
7820
|
+
return cidrContains(aVal, bVal);
|
|
7723
7821
|
return false;
|
|
7724
7822
|
}
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7823
|
+
var INTERFACE_LIST_PAIRS = [
|
|
7824
|
+
["in-interface-list", "in-interface"],
|
|
7825
|
+
["out-interface-list", "out-interface"]
|
|
7826
|
+
];
|
|
7827
|
+
var _ifaceLists;
|
|
7828
|
+
function interfaceListCovers(aKey, aVal, bKey, bVal) {
|
|
7829
|
+
if (!_ifaceLists)
|
|
7830
|
+
return;
|
|
7831
|
+
for (const [listKey, ifaceKey] of INTERFACE_LIST_PAIRS) {
|
|
7832
|
+
if (aKey === listKey && bKey === ifaceKey) {
|
|
7833
|
+
const negated = aVal.startsWith("!");
|
|
7834
|
+
const listName = negated ? aVal.slice(1) : aVal;
|
|
7835
|
+
const members = _ifaceLists.get(listName);
|
|
7836
|
+
if (!members)
|
|
7837
|
+
return;
|
|
7838
|
+
const isMember = members.has(bVal);
|
|
7839
|
+
return negated ? !isMember : isMember;
|
|
7840
|
+
}
|
|
7841
|
+
}
|
|
7842
|
+
return;
|
|
7728
7843
|
}
|
|
7729
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7844
|
+
function aCoversB(a, b) {
|
|
7845
|
+
for (const [k, v] of Object.entries(a.match)) {
|
|
7846
|
+
const bv = b.match[k];
|
|
7847
|
+
if (bv !== undefined) {
|
|
7848
|
+
if (!covers(k, v, bv))
|
|
7849
|
+
return false;
|
|
7850
|
+
continue;
|
|
7851
|
+
}
|
|
7852
|
+
let crossCovered = false;
|
|
7853
|
+
for (const [bk, bval] of Object.entries(b.match)) {
|
|
7854
|
+
const result = interfaceListCovers(k, v, bk, bval);
|
|
7855
|
+
if (result === true) {
|
|
7856
|
+
crossCovered = true;
|
|
7857
|
+
break;
|
|
7858
|
+
}
|
|
7859
|
+
}
|
|
7860
|
+
if (!crossCovered)
|
|
7861
|
+
return false;
|
|
7862
|
+
}
|
|
7863
|
+
return true;
|
|
7735
7864
|
}
|
|
7736
|
-
|
|
7737
|
-
|
|
7738
|
-
|
|
7739
|
-
|
|
7740
|
-
|
|
7865
|
+
function matchesAll(rule) {
|
|
7866
|
+
for (const [k, v] of Object.entries(rule.match)) {
|
|
7867
|
+
if (ADDRESS_KEYS.has(k) && CATCH_ALL_ADDR.has(v))
|
|
7868
|
+
continue;
|
|
7869
|
+
return false;
|
|
7870
|
+
}
|
|
7871
|
+
return true;
|
|
7741
7872
|
}
|
|
7742
|
-
|
|
7743
|
-
|
|
7744
|
-
if (!n || n <= 0)
|
|
7745
|
-
return text;
|
|
7746
|
-
const lines = text.split(`
|
|
7747
|
-
`);
|
|
7748
|
-
return lines.length <= n ? text : lines.slice(-n).join(`
|
|
7749
|
-
`);
|
|
7873
|
+
function hasNondeterministic(rule) {
|
|
7874
|
+
return Object.keys(rule.match).some((k) => NONDETERMINISTIC.has(k));
|
|
7750
7875
|
}
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
7876
|
+
function matchSummary(rule) {
|
|
7877
|
+
const order = [
|
|
7878
|
+
"protocol",
|
|
7879
|
+
"src-address",
|
|
7880
|
+
"src-port",
|
|
7881
|
+
"dst-address",
|
|
7882
|
+
"dst-port",
|
|
7883
|
+
"in-interface",
|
|
7884
|
+
"out-interface",
|
|
7885
|
+
"in-interface-list",
|
|
7886
|
+
"out-interface-list",
|
|
7887
|
+
"connection-state",
|
|
7888
|
+
"src-address-list",
|
|
7889
|
+
"dst-address-list"
|
|
7890
|
+
];
|
|
7891
|
+
const parts = order.filter((k) => rule.match[k]).map((k) => `${k}=${rule.match[k]}`);
|
|
7892
|
+
return parts.length ? parts.join(" ") : "any";
|
|
7759
7893
|
}
|
|
7760
|
-
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
}
|
|
7764
|
-
|
|
7765
|
-
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
detail:
|
|
7775
|
-
|
|
7776
|
-
|
|
7777
|
-
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7894
|
+
function disableAction(table, index) {
|
|
7895
|
+
const tool = table === "filter" ? "disable_filter_rule" : table === "nat" ? "disable_nat_rule" : undefined;
|
|
7896
|
+
return tool ? { tool, args: { rule_id: String(index) }, label: `Disable rule ${index}` } : undefined;
|
|
7897
|
+
}
|
|
7898
|
+
function auditFilter(rules) {
|
|
7899
|
+
const findings = [];
|
|
7900
|
+
const active2 = rules.filter((r) => !r.disabled && !r.dynamic);
|
|
7901
|
+
if (rules.length === 0) {
|
|
7902
|
+
findings.push({
|
|
7903
|
+
kind: "no-firewall",
|
|
7904
|
+
severity: "high",
|
|
7905
|
+
table: "filter",
|
|
7906
|
+
chain: "input",
|
|
7907
|
+
title: "No firewall configured",
|
|
7908
|
+
detail: "No firewall filter rules are configured. RouterOS's default policy is ACCEPT, " + "so the device currently allows all input and forwarded traffic.",
|
|
7909
|
+
suggestion: "Add a baseline ruleset: accept established/related, drop invalid, accept what you need, then drop everything else."
|
|
7910
|
+
});
|
|
7911
|
+
return findings;
|
|
7912
|
+
}
|
|
7913
|
+
const chains = [...new Set(active2.map((r) => r.chain))];
|
|
7914
|
+
for (const chain of chains) {
|
|
7915
|
+
const chainRules = active2.filter((r) => r.chain === chain);
|
|
7916
|
+
for (let j = 0;j < chainRules.length; j++) {
|
|
7917
|
+
const b = chainRules[j];
|
|
7918
|
+
if (b.action === "accept" && matchesAll(b)) {
|
|
7919
|
+
const sev = chain === "output" ? "low" : "high";
|
|
7920
|
+
findings.push({
|
|
7921
|
+
kind: "broad-accept",
|
|
7922
|
+
severity: sev,
|
|
7923
|
+
table: "filter",
|
|
7924
|
+
chain,
|
|
7925
|
+
ruleIndex: b.index,
|
|
7926
|
+
title: "Overly broad accept",
|
|
7927
|
+
detail: `Rule ${b.index} accepts ALL traffic in the ${chain} chain (no real match conditions), bypassing every rule after it.`,
|
|
7928
|
+
suggestion: `Scope rule ${b.index} to the specific source/port it should allow, or remove it.`,
|
|
7929
|
+
action: disableAction("filter", b.index)
|
|
7930
|
+
});
|
|
7931
|
+
}
|
|
7932
|
+
for (let i = 0;i < j; i++) {
|
|
7933
|
+
const a = chainRules[i];
|
|
7934
|
+
if (!TERMINAL.has(a.action) || hasNondeterministic(a))
|
|
7935
|
+
continue;
|
|
7936
|
+
if (aCoversB(a, b)) {
|
|
7937
|
+
findings.push({
|
|
7938
|
+
kind: "shadowed",
|
|
7939
|
+
severity: "medium",
|
|
7940
|
+
table: "filter",
|
|
7941
|
+
chain,
|
|
7942
|
+
ruleIndex: b.index,
|
|
7943
|
+
relatedIndex: a.index,
|
|
7944
|
+
title: "Unreachable rule",
|
|
7945
|
+
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.`,
|
|
7946
|
+
suggestion: `Remove rule ${b.index}, or move it above rule ${a.index} if it was meant to take effect first.`,
|
|
7947
|
+
action: disableAction("filter", b.index)
|
|
7948
|
+
});
|
|
7949
|
+
break;
|
|
7950
|
+
}
|
|
7951
|
+
}
|
|
7952
|
+
}
|
|
7953
|
+
if ((chain === "input" || chain === "forward") && chainRules.length > 0) {
|
|
7954
|
+
const hasDrop = chainRules.some((r) => (r.action === "drop" || r.action === "reject") && matchesAll(r));
|
|
7955
|
+
if (!hasDrop) {
|
|
7956
|
+
findings.push({
|
|
7957
|
+
kind: "missing-default-drop",
|
|
7958
|
+
severity: "high",
|
|
7959
|
+
table: "filter",
|
|
7960
|
+
chain,
|
|
7961
|
+
title: "No default-drop",
|
|
7962
|
+
detail: `The ${chain} chain has no catch-all drop, so anything not explicitly accepted is ACCEPTED (RouterOS default policy).`,
|
|
7963
|
+
suggestion: `Append a 'drop all' rule at the end of the ${chain} chain.`
|
|
7964
|
+
});
|
|
7965
|
+
}
|
|
7966
|
+
}
|
|
7967
|
+
}
|
|
7968
|
+
findings.push(...duplicateFindings(active2, "filter"));
|
|
7969
|
+
findings.push(...deadFindings(active2, "filter"));
|
|
7970
|
+
return findings;
|
|
7971
|
+
}
|
|
7972
|
+
function auditTransform(rules, table) {
|
|
7973
|
+
const active2 = rules.filter((r) => !r.disabled && !r.dynamic);
|
|
7974
|
+
return [...duplicateFindings(active2, table), ...deadFindings(active2, table)];
|
|
7975
|
+
}
|
|
7976
|
+
function ruleKey(r) {
|
|
7977
|
+
const m = Object.entries(r.match).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${k}=${v}`).join("&");
|
|
7978
|
+
const t = Object.entries(r.transform).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${k}=${v}`).join("&");
|
|
7979
|
+
return `${r.chain}|${r.action}|${m}|${t}`;
|
|
7980
|
+
}
|
|
7981
|
+
function duplicateFindings(active2, table) {
|
|
7982
|
+
const findings = [];
|
|
7983
|
+
const seen = new Map;
|
|
7984
|
+
for (const r of active2) {
|
|
7985
|
+
const key = ruleKey(r);
|
|
7986
|
+
const first = seen.get(key);
|
|
7987
|
+
if (first) {
|
|
7988
|
+
findings.push({
|
|
7989
|
+
kind: "duplicate",
|
|
7990
|
+
severity: "medium",
|
|
7991
|
+
table,
|
|
7992
|
+
chain: r.chain,
|
|
7993
|
+
ruleIndex: r.index,
|
|
7994
|
+
relatedIndex: first.index,
|
|
7995
|
+
title: "Duplicate rule",
|
|
7996
|
+
detail: `Rule ${r.index} in the ${r.chain} chain is identical to rule ${first.index} (same match and action) \u2014 it is redundant.`,
|
|
7997
|
+
suggestion: `Remove the duplicate rule ${r.index}.`,
|
|
7998
|
+
action: disableAction(table, r.index)
|
|
7999
|
+
});
|
|
8000
|
+
} else {
|
|
8001
|
+
seen.set(key, r);
|
|
8002
|
+
}
|
|
8003
|
+
}
|
|
8004
|
+
return findings;
|
|
8005
|
+
}
|
|
8006
|
+
function deadFindings(active2, table) {
|
|
8007
|
+
const findings = [];
|
|
8008
|
+
for (const r of active2) {
|
|
8009
|
+
if (r.packets === 0) {
|
|
8010
|
+
findings.push({
|
|
8011
|
+
kind: "dead-rule",
|
|
8012
|
+
severity: "low",
|
|
8013
|
+
table,
|
|
8014
|
+
chain: r.chain,
|
|
8015
|
+
ruleIndex: r.index,
|
|
8016
|
+
title: "No hits since boot",
|
|
8017
|
+
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).`,
|
|
8018
|
+
suggestion: `Confirm rule ${r.index} is still needed; remove it if obsolete.`
|
|
8019
|
+
});
|
|
8020
|
+
}
|
|
8021
|
+
}
|
|
8022
|
+
return findings;
|
|
8023
|
+
}
|
|
8024
|
+
var WEIGHT2 = { high: 20, medium: 8, low: 2 };
|
|
8025
|
+
function grade2(score) {
|
|
8026
|
+
if (score === 0)
|
|
8027
|
+
return "clean";
|
|
8028
|
+
if (score < 15)
|
|
8029
|
+
return "good";
|
|
8030
|
+
if (score < 40)
|
|
8031
|
+
return "fair";
|
|
8032
|
+
if (score < 75)
|
|
8033
|
+
return "poor";
|
|
8034
|
+
return "critical";
|
|
8035
|
+
}
|
|
8036
|
+
function auditFirewall(input) {
|
|
8037
|
+
_ifaceLists = input.interfaceLists;
|
|
8038
|
+
const findings = [];
|
|
8039
|
+
if (input.filter)
|
|
8040
|
+
findings.push(...auditFilter(input.filter));
|
|
8041
|
+
if (input.nat)
|
|
8042
|
+
findings.push(...auditTransform(input.nat, "nat"));
|
|
8043
|
+
if (input.mangle)
|
|
8044
|
+
findings.push(...auditTransform(input.mangle, "mangle"));
|
|
8045
|
+
const sevRank = { high: 0, medium: 1, low: 2 };
|
|
8046
|
+
findings.sort((a, b) => sevRank[a.severity] - sevRank[b.severity] || a.table.localeCompare(b.table) || (a.ruleIndex ?? -1) - (b.ruleIndex ?? -1));
|
|
8047
|
+
const counts = { high: 0, medium: 0, low: 0, total: findings.length };
|
|
8048
|
+
let raw = 0;
|
|
8049
|
+
for (const f of findings) {
|
|
8050
|
+
counts[f.severity]++;
|
|
8051
|
+
raw += WEIGHT2[f.severity];
|
|
8052
|
+
}
|
|
8053
|
+
const riskScore = Math.min(100, raw);
|
|
8054
|
+
const ruleCount = (input.filter?.length ?? 0) + (input.nat?.length ?? 0) + (input.mangle?.length ?? 0);
|
|
8055
|
+
return { riskScore, grade: grade2(riskScore), counts, ruleCount, findings };
|
|
8056
|
+
}
|
|
8057
|
+
function renderReport(report, device) {
|
|
8058
|
+
const head = `FIREWALL AUDIT \u2014 ${device}
|
|
8059
|
+
|
|
8060
|
+
` + `Risk score: ${report.riskScore}/100 (${report.grade})
|
|
8061
|
+
` + `${report.ruleCount} rule(s) analysed \xB7 ${report.counts.high} high, ${report.counts.medium} medium, ${report.counts.low} low
|
|
8062
|
+
`;
|
|
8063
|
+
if (report.findings.length === 0) {
|
|
8064
|
+
return `${head}
|
|
8065
|
+
No issues found \u2014 the ruleset looks clean. \u2713`;
|
|
8066
|
+
}
|
|
8067
|
+
const body = report.findings.map((f, i) => {
|
|
8068
|
+
const tag = f.severity.toUpperCase().padEnd(6);
|
|
8069
|
+
return `${i + 1}. [${tag}] ${f.title} (${f.table}/${f.chain})
|
|
8070
|
+
${f.detail}
|
|
8071
|
+
\u2192 ${f.suggestion}`;
|
|
8072
|
+
}).join(`
|
|
8073
|
+
|
|
8074
|
+
`);
|
|
8075
|
+
return `${head}
|
|
8076
|
+
${body}`;
|
|
8077
|
+
}
|
|
8078
|
+
|
|
8079
|
+
// src/utils/firewall-query.ts
|
|
8080
|
+
async function fetchFilterChainRules(chain, ctx) {
|
|
8081
|
+
const rows = await fetchRows(`/ip firewall filter print detail where chain=${chain}`, ctx);
|
|
8082
|
+
return rulesFromRows(rows);
|
|
8083
|
+
}
|
|
8084
|
+
async function addressListCount(list, ctx) {
|
|
8085
|
+
const raw = await executeMikrotikCommand(`/ip firewall address-list print count-only where list=${JSON.stringify(list)}`, ctx);
|
|
8086
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
8087
|
+
return Number.isFinite(n) ? n : 0;
|
|
8088
|
+
}
|
|
8089
|
+
async function filterChainRuleIds(chain, ctx) {
|
|
8090
|
+
const raw = await executeMikrotikCommand(`:foreach i in=[/ip firewall filter find chain=${chain}] do={:put $i}`, ctx);
|
|
8091
|
+
if (isEmpty(raw))
|
|
8092
|
+
return [];
|
|
8093
|
+
return raw.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => /^\*[0-9A-Fa-f]+$/.test(l));
|
|
8094
|
+
}
|
|
8095
|
+
// src/utils/ip.ts
|
|
8096
|
+
function isIpAddress(s) {
|
|
8097
|
+
return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(s);
|
|
8098
|
+
}
|
|
8099
|
+
function isIpLike(s) {
|
|
8100
|
+
return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d+)?$/.test(s);
|
|
8101
|
+
}
|
|
8102
|
+
function isPrivateIp(ip) {
|
|
8103
|
+
const p = ip.split(".").map(Number);
|
|
8104
|
+
if (p.length !== 4)
|
|
8105
|
+
return false;
|
|
8106
|
+
if (p[0] === 10)
|
|
8107
|
+
return true;
|
|
8108
|
+
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31)
|
|
8109
|
+
return true;
|
|
8110
|
+
if (p[0] === 192 && p[1] === 168)
|
|
8111
|
+
return true;
|
|
8112
|
+
if (p[0] === 169 && p[1] === 254)
|
|
8113
|
+
return true;
|
|
8114
|
+
return false;
|
|
8115
|
+
}
|
|
8116
|
+
// src/utils/num.ts
|
|
8117
|
+
function num(s) {
|
|
8118
|
+
return Number.parseInt(s ?? "0", 10) || 0;
|
|
8119
|
+
}
|
|
8120
|
+
// src/utils/or-match.ts
|
|
8121
|
+
function orMatch(field, values, op = "~") {
|
|
8122
|
+
if (values.length === 0)
|
|
8123
|
+
return "";
|
|
8124
|
+
const terms = values.map((v) => `${field}${op}"${v.replace(/"/g, "\\\"")}"`);
|
|
8125
|
+
return `(${terms.join(" or ")})`;
|
|
8126
|
+
}
|
|
8127
|
+
// src/utils/redact-secrets.ts
|
|
8128
|
+
var SENSITIVE_KEYS = ["password", "shared-secret", "secret"];
|
|
8129
|
+
var REDACT_RE = new RegExp(`(?<![\\w-])(${SENSITIVE_KEYS.join("|")})="[^"]*"`, "g");
|
|
8130
|
+
function redactSecrets(text) {
|
|
8131
|
+
return text.replace(REDACT_RE, '$1="***"');
|
|
8132
|
+
}
|
|
8133
|
+
// src/utils/safe-mode-apply.ts
|
|
8134
|
+
async function applyCommandsDirect(ctx, commands) {
|
|
8135
|
+
let applied = 0;
|
|
8136
|
+
for (const cmd of commands) {
|
|
8137
|
+
const out = await executeMikrotikCommand(cmd, ctx).catch((e) => `error: ${String(e)}`);
|
|
8138
|
+
if (looksLikeError(out) || out.startsWith("error:")) {
|
|
8139
|
+
return { applied, error: out.trim().split(`
|
|
8140
|
+
`)[0] };
|
|
8141
|
+
}
|
|
8142
|
+
applied++;
|
|
8143
|
+
}
|
|
8144
|
+
return { applied };
|
|
8145
|
+
}
|
|
8146
|
+
async function applyWritesSafely(ctx, deviceName, commands, opts = {}) {
|
|
8147
|
+
const total = commands.length;
|
|
8148
|
+
const fallback = opts.allowDirectFallback === true;
|
|
8149
|
+
if (total === 0)
|
|
8150
|
+
return {
|
|
8151
|
+
applied: 0,
|
|
8152
|
+
total,
|
|
8153
|
+
safeMode: "not used (nothing to write)",
|
|
8154
|
+
committed: true,
|
|
8155
|
+
fellBack: false
|
|
8156
|
+
};
|
|
8157
|
+
if (getDevice(deviceName).mac) {
|
|
8158
|
+
if (!fallback)
|
|
8159
|
+
return {
|
|
8160
|
+
applied: 0,
|
|
8161
|
+
total,
|
|
8162
|
+
safeMode: "unavailable (MAC-Telnet device has no Safe Mode)",
|
|
8163
|
+
committed: false,
|
|
8164
|
+
fellBack: false
|
|
8165
|
+
};
|
|
8166
|
+
const r = await applyCommandsDirect(ctx, commands);
|
|
8167
|
+
return {
|
|
8168
|
+
applied: r.applied,
|
|
8169
|
+
total,
|
|
8170
|
+
safeMode: "not used (MAC-Telnet device \u2014 no Safe Mode)",
|
|
8171
|
+
committed: !r.error,
|
|
8172
|
+
error: r.error,
|
|
8173
|
+
fellBack: false
|
|
8174
|
+
};
|
|
8175
|
+
}
|
|
8176
|
+
const mgr = getSafeModeManager(deviceName);
|
|
8177
|
+
const en = await mgr.enable();
|
|
8178
|
+
if (en.startsWith("Error")) {
|
|
8179
|
+
if (!fallback)
|
|
8180
|
+
return {
|
|
8181
|
+
applied: 0,
|
|
8182
|
+
total,
|
|
8183
|
+
safeMode: `failed to enable: ${en}`,
|
|
8184
|
+
committed: false,
|
|
8185
|
+
fellBack: false
|
|
8186
|
+
};
|
|
8187
|
+
const r = await applyCommandsDirect(ctx, commands);
|
|
8188
|
+
return {
|
|
8189
|
+
applied: r.applied,
|
|
8190
|
+
total,
|
|
8191
|
+
safeMode: `unavailable (${en.replace(/^Error:?\s*/, "")}) \u2014 applied directly; snapshot is the rollback point`,
|
|
8192
|
+
committed: !r.error,
|
|
8193
|
+
error: r.error,
|
|
8194
|
+
fellBack: true
|
|
8195
|
+
};
|
|
8196
|
+
}
|
|
8197
|
+
let applied = 0;
|
|
8198
|
+
let wedged;
|
|
8199
|
+
for (const cmd of commands) {
|
|
8200
|
+
const out = await mgr.execute(cmd).catch((e) => `error: ${String(e)}`);
|
|
8201
|
+
if (looksLikeError(out) || out.startsWith("error:")) {
|
|
8202
|
+
wedged = out.trim().split(`
|
|
8203
|
+
`)[0];
|
|
8204
|
+
break;
|
|
8205
|
+
}
|
|
8206
|
+
applied++;
|
|
8207
|
+
}
|
|
8208
|
+
if (wedged !== undefined) {
|
|
8209
|
+
await mgr.rollback().catch(() => {
|
|
8210
|
+
return;
|
|
8211
|
+
});
|
|
8212
|
+
if (!fallback)
|
|
8213
|
+
return {
|
|
8214
|
+
applied,
|
|
8215
|
+
total,
|
|
8216
|
+
safeMode: `rolled back (a write failed: ${wedged})`,
|
|
8217
|
+
committed: false,
|
|
8218
|
+
error: wedged,
|
|
8219
|
+
fellBack: false
|
|
8220
|
+
};
|
|
8221
|
+
const r = await applyCommandsDirect(ctx, commands);
|
|
8222
|
+
return {
|
|
8223
|
+
applied: r.applied,
|
|
8224
|
+
total,
|
|
8225
|
+
safeMode: `Safe Mode wedged (${wedged}) \u2014 rolled back and re-applied directly (snapshot is the rollback point)`,
|
|
8226
|
+
committed: !r.error,
|
|
8227
|
+
error: r.error,
|
|
8228
|
+
fellBack: true
|
|
8229
|
+
};
|
|
8230
|
+
}
|
|
8231
|
+
const c = await mgr.commit();
|
|
8232
|
+
return {
|
|
8233
|
+
applied,
|
|
8234
|
+
total,
|
|
8235
|
+
safeMode: c.ok ? "committed" : `commit unclear (${c.message}) \u2014 re-run to reconcile if the operation is idempotent`,
|
|
8236
|
+
committed: c.ok,
|
|
8237
|
+
fellBack: false
|
|
8238
|
+
};
|
|
8239
|
+
}
|
|
8240
|
+
// src/utils/tail-lines.ts
|
|
8241
|
+
function tailLines(text, n) {
|
|
8242
|
+
if (!n || n <= 0)
|
|
8243
|
+
return text;
|
|
8244
|
+
const lines = text.split(`
|
|
8245
|
+
`);
|
|
8246
|
+
return lines.length <= n ? text : lines.slice(-n).join(`
|
|
8247
|
+
`);
|
|
8248
|
+
}
|
|
8249
|
+
// src/tools/container.ts
|
|
8250
|
+
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).";
|
|
8251
|
+
function containerMatch(name, tag) {
|
|
8252
|
+
if (name)
|
|
8253
|
+
return `name=${quoteValue(name)}`;
|
|
8254
|
+
if (tag)
|
|
8255
|
+
return `tag~${quoteValue(tag)}`;
|
|
8256
|
+
return null;
|
|
8257
|
+
}
|
|
8258
|
+
var IDENTITY = {
|
|
8259
|
+
name: z17.string().optional().describe("Container name (set via add_container)"),
|
|
8260
|
+
tag: z17.string().optional().describe("Image tag to match (e.g. 'pihole') if no name")
|
|
8261
|
+
};
|
|
8262
|
+
var containerTools = [
|
|
8263
|
+
defineTool({
|
|
8264
|
+
name: "list_containers",
|
|
8265
|
+
title: "List Containers",
|
|
8266
|
+
annotations: READ,
|
|
8267
|
+
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.',
|
|
8268
|
+
inputSchema: {
|
|
8269
|
+
name_filter: z17.string().optional().describe("Partial container name match"),
|
|
8270
|
+
tag_filter: z17.string().optional().describe("Partial image tag match"),
|
|
8271
|
+
status_filter: z17.string().optional().describe("e.g. 'running', 'stopped'"),
|
|
8272
|
+
detail: z17.boolean().default(false).describe("Show the full per-container property block")
|
|
8273
|
+
},
|
|
8274
|
+
async handler(a, ctx) {
|
|
8275
|
+
ctx.info("Listing containers");
|
|
8276
|
+
const filters = [];
|
|
8277
|
+
if (a.name_filter)
|
|
8278
|
+
filters.push(`name~"${a.name_filter}"`);
|
|
8279
|
+
if (a.tag_filter)
|
|
8280
|
+
filters.push(`tag~"${a.tag_filter}"`);
|
|
8281
|
+
if (a.status_filter)
|
|
8282
|
+
filters.push(`status~"${a.status_filter}"`);
|
|
8283
|
+
const result = await executeMikrotikCommand(`/container print${a.detail ? " detail" : ""}${whereClause(filters)}`, ctx);
|
|
8284
|
+
if (commandUnsupported(result))
|
|
8285
|
+
return NOT_AVAILABLE;
|
|
8286
|
+
return isEmpty(result) ? "No containers found matching the criteria." : `CONTAINERS:
|
|
8287
|
+
|
|
8288
|
+
${redactSecrets(result)}`;
|
|
8289
|
+
}
|
|
8290
|
+
}),
|
|
8291
|
+
defineTool({
|
|
8292
|
+
name: "get_container",
|
|
7795
8293
|
title: "Get Container Detail",
|
|
7796
8294
|
annotations: READ,
|
|
7797
8295
|
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.",
|
|
@@ -8497,7 +8995,7 @@ var cache = null;
|
|
|
8497
8995
|
async function gateway() {
|
|
8498
8996
|
if (cache)
|
|
8499
8997
|
return cache;
|
|
8500
|
-
const { moduleCatalog } = await import("./cli-
|
|
8998
|
+
const { moduleCatalog } = await import("./cli-cb2zp05h.js");
|
|
8501
8999
|
const forIndex = [];
|
|
8502
9000
|
const byName = new Map;
|
|
8503
9001
|
for (const mod of moduleCatalog) {
|
|
@@ -8912,873 +9410,496 @@ ${result}`;
|
|
|
8912
9410
|
name: "disable_dns_static",
|
|
8913
9411
|
title: "Disable DNS Static Record",
|
|
8914
9412
|
annotations: WRITE_IDEMPOTENT,
|
|
8915
|
-
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.",
|
|
8916
|
-
inputSchema: { entry_id: z21.string() },
|
|
8917
|
-
handler: (a, ctx) => updateDnsStatic({ entry_id: a.entry_id, disabled: true }, ctx)
|
|
8918
|
-
}),
|
|
8919
|
-
defineTool({
|
|
8920
|
-
name: "get_dns_cache",
|
|
8921
|
-
title: "Get DNS Cache Entries",
|
|
8922
|
-
annotations: READ,
|
|
8923
|
-
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.",
|
|
8924
|
-
async handler(_a, ctx) {
|
|
8925
|
-
ctx.info("Getting DNS cache");
|
|
8926
|
-
const result = await executeMikrotikCommand("/ip dns cache print", ctx);
|
|
8927
|
-
return isEmpty(result) ? "DNS cache is empty." : `DNS CACHE:
|
|
8928
|
-
|
|
8929
|
-
${result}`;
|
|
8930
|
-
}
|
|
8931
|
-
}),
|
|
8932
|
-
defineTool({
|
|
8933
|
-
name: "flush_dns_cache",
|
|
8934
|
-
title: "Flush DNS Cache",
|
|
8935
|
-
annotations: DESTRUCTIVE,
|
|
8936
|
-
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.",
|
|
8937
|
-
async handler(_a, ctx) {
|
|
8938
|
-
ctx.info("Flushing DNS cache");
|
|
8939
|
-
const result = await executeMikrotikCommand("/ip dns cache flush", ctx);
|
|
8940
|
-
return result.trim() ? `Flush result: ${result}` : "DNS cache flushed successfully.";
|
|
8941
|
-
}
|
|
8942
|
-
}),
|
|
8943
|
-
defineTool({
|
|
8944
|
-
name: "get_dns_cache_statistics",
|
|
8945
|
-
title: "Get DNS Cache Statistics",
|
|
8946
|
-
annotations: READ,
|
|
8947
|
-
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.",
|
|
8948
|
-
async handler(_a, ctx) {
|
|
8949
|
-
ctx.info("Getting DNS cache statistics");
|
|
8950
|
-
const settings = await executeMikrotikCommand("/ip dns print", ctx);
|
|
8951
|
-
if (looksLikeError(settings))
|
|
8952
|
-
return `Failed to get DNS cache statistics: ${settings}`;
|
|
8953
|
-
if (isEmpty(settings))
|
|
8954
|
-
return "Unable to retrieve DNS cache statistics.";
|
|
8955
|
-
const cacheLines = settings.split(`
|
|
8956
|
-
`).filter((l) => l.toLowerCase().includes("cache"));
|
|
8957
|
-
const stats = cacheLines.length ? cacheLines.join(`
|
|
8958
|
-
`) : settings.trim();
|
|
8959
|
-
const count = (await executeMikrotikCommand("/ip dns cache print count-only", ctx)).trim();
|
|
8960
|
-
const entryLine = /^\d+$/.test(count) ? `cached-entries: ${count}
|
|
8961
|
-
` : "";
|
|
8962
|
-
return `DNS CACHE STATISTICS:
|
|
8963
|
-
|
|
8964
|
-
${entryLine}${stats}`;
|
|
8965
|
-
}
|
|
8966
|
-
}),
|
|
8967
|
-
defineTool({
|
|
8968
|
-
name: "add_dns_regexp",
|
|
8969
|
-
title: "Add DNS Regexp Static Record",
|
|
8970
|
-
annotations: WRITE,
|
|
8971
|
-
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`.",
|
|
8972
|
-
inputSchema: {
|
|
8973
|
-
regexp: z21.string(),
|
|
8974
|
-
address: z21.string(),
|
|
8975
|
-
ttl: z21.string().default("1d"),
|
|
8976
|
-
comment: z21.string().optional(),
|
|
8977
|
-
disabled: z21.boolean().default(false)
|
|
8978
|
-
},
|
|
8979
|
-
handler: (a, ctx) => addDnsStatic({
|
|
8980
|
-
name: "dummy",
|
|
8981
|
-
address: a.address,
|
|
8982
|
-
regexp: a.regexp,
|
|
8983
|
-
ttl: a.ttl,
|
|
8984
|
-
comment: a.comment,
|
|
8985
|
-
disabled: a.disabled
|
|
8986
|
-
}, ctx)
|
|
8987
|
-
}),
|
|
8988
|
-
defineTool({
|
|
8989
|
-
name: "test_dns_query",
|
|
8990
|
-
title: "Test DNS Resolution From Router",
|
|
8991
|
-
annotations: READ,
|
|
8992
|
-
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.",
|
|
8993
|
-
inputSchema: {
|
|
8994
|
-
name: z21.string(),
|
|
8995
|
-
server: z21.string().optional(),
|
|
8996
|
-
type: z21.string().default("A")
|
|
8997
|
-
},
|
|
8998
|
-
async handler(a, ctx) {
|
|
8999
|
-
ctx.info(`Testing DNS query: name=${a.name}, type=${a.type}`);
|
|
9000
|
-
let cmd = `/resolve ${a.name}`;
|
|
9001
|
-
if (a.server)
|
|
9002
|
-
cmd += ` server=${a.server}`;
|
|
9003
|
-
if (a.type !== "A")
|
|
9004
|
-
cmd += ` type=${a.type}`;
|
|
9005
|
-
const result = await executeMikrotikCommand(cmd, ctx);
|
|
9006
|
-
return isEmpty(result) ? `Failed to resolve ${a.name}` : `DNS QUERY RESULT for ${a.name}:
|
|
9007
|
-
|
|
9008
|
-
${result}`;
|
|
9009
|
-
}
|
|
9010
|
-
}),
|
|
9011
|
-
defineTool({
|
|
9012
|
-
name: "export_dns_config",
|
|
9013
|
-
title: "Export DNS Configuration to File",
|
|
9014
|
-
annotations: READ,
|
|
9015
|
-
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.",
|
|
9016
|
-
inputSchema: { filename: z21.string().optional() },
|
|
9017
|
-
async handler(a, ctx) {
|
|
9018
|
-
ctx.info("Exporting DNS configuration");
|
|
9019
|
-
const filename = a.filename || "dns_config";
|
|
9020
|
-
const result = await executeMikrotikCommand(`/ip dns export file=${filename}`, ctx);
|
|
9021
|
-
return result.trim() ? `Export result: ${result}` : `DNS configuration exported to ${filename}.rsc`;
|
|
9022
|
-
}
|
|
9023
|
-
})
|
|
9024
|
-
];
|
|
9025
|
-
|
|
9026
|
-
// src/tools/parental-controls.ts
|
|
9027
|
-
import { z as z22 } from "zod";
|
|
9028
|
-
function buildPolicyCommands(o) {
|
|
9029
|
-
const tag = `parental-${o.name}`;
|
|
9030
|
-
const groups = [];
|
|
9031
|
-
if (o.addresses?.length) {
|
|
9032
|
-
groups.push({
|
|
9033
|
-
label: "Target devices",
|
|
9034
|
-
commands: o.addresses.map((addr) => new Cmd("/ip firewall address-list add").set("list", o.list).set("address", addr).set("comment", tag).build())
|
|
9035
|
-
});
|
|
9036
|
-
}
|
|
9037
|
-
groups.push({
|
|
9038
|
-
label: "Scheduled internet cut-off",
|
|
9039
|
-
commands: [
|
|
9040
|
-
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(),
|
|
9041
|
-
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(),
|
|
9042
|
-
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()
|
|
9043
|
-
]
|
|
9044
|
-
});
|
|
9045
|
-
if (o.blockDomains?.length) {
|
|
9046
|
-
groups.push({
|
|
9047
|
-
label: "Content blocking (DNS sinkhole)",
|
|
9048
|
-
commands: o.blockDomains.map((d) => new Cmd("/ip dns static add").set("name", d).set("address", "0.0.0.0").set("comment", tag).build())
|
|
9049
|
-
});
|
|
9050
|
-
}
|
|
9051
|
-
return groups;
|
|
9052
|
-
}
|
|
9053
|
-
var parentalControlsTools = [
|
|
9054
|
-
defineTool({
|
|
9055
|
-
name: "set_time_policy",
|
|
9056
|
-
title: "Set Time-of-Day / Parental Policy",
|
|
9057
|
-
annotations: WRITE,
|
|
9058
|
-
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.",
|
|
9059
|
-
inputSchema: {
|
|
9060
|
-
name: z22.string().describe("Policy id, e.g. 'kids-bedtime'"),
|
|
9061
|
-
list: z22.string().default("parental-devices").describe("Address-list of the affected devices"),
|
|
9062
|
-
addresses: z22.array(z22.string()).optional().describe("Device IPs to add to the list"),
|
|
9063
|
-
block_start: z22.string().default("22:00").describe("Daily cut-off time (HH:MM)"),
|
|
9064
|
-
block_end: z22.string().default("07:00").describe("Daily restore time (HH:MM)"),
|
|
9065
|
-
block_domains: z22.array(z22.string()).optional().describe("Domains to always block via DNS"),
|
|
9066
|
-
apply: z22.boolean().default(false).describe("false = preview (default); true = install")
|
|
9067
|
-
},
|
|
9068
|
-
async handler(a, ctx) {
|
|
9069
|
-
const groups = buildPolicyCommands({
|
|
9070
|
-
name: a.name,
|
|
9071
|
-
list: a.list,
|
|
9072
|
-
addresses: a.addresses,
|
|
9073
|
-
blockStart: a.block_start,
|
|
9074
|
-
blockEnd: a.block_end,
|
|
9075
|
-
blockDomains: a.block_domains
|
|
9076
|
-
});
|
|
9077
|
-
const all = groups.flatMap((g) => g.commands);
|
|
9078
|
-
if (!a.apply) {
|
|
9079
|
-
const preview = groups.map((g) => `# ${g.label}
|
|
9080
|
-
${g.commands.map((c) => ` ${c}`).join(`
|
|
9081
|
-
`)}`).join(`
|
|
9082
|
-
|
|
9083
|
-
`);
|
|
9084
|
-
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):
|
|
9085
|
-
|
|
9086
|
-
${preview}`;
|
|
9087
|
-
}
|
|
9088
|
-
const done = [];
|
|
9089
|
-
for (const cmd of all) {
|
|
9090
|
-
const result = await executeMikrotikCommand(cmd, ctx);
|
|
9091
|
-
if (looksLikeError(result)) {
|
|
9092
|
-
return `Installed ${done.length}/${all.length}, then FAILED: ${result}`;
|
|
9093
|
-
}
|
|
9094
|
-
done.push(cmd);
|
|
9095
|
-
}
|
|
9096
|
-
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` : ""}.`;
|
|
9097
|
-
}
|
|
9098
|
-
}),
|
|
9099
|
-
defineTool({
|
|
9100
|
-
name: "remove_time_policy",
|
|
9101
|
-
title: "Remove Time-of-Day / Parental Policy",
|
|
9102
|
-
annotations: DESTRUCTIVE,
|
|
9103
|
-
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.",
|
|
9104
|
-
inputSchema: { name: z22.string().describe("The policy id used when installing") },
|
|
9105
|
-
async handler(a, ctx) {
|
|
9106
|
-
const tag = `parental-${a.name}`;
|
|
9107
|
-
ctx.info(`Removing parental policy ${tag}`);
|
|
9108
|
-
const steps = [
|
|
9109
|
-
["schedulers", `/system scheduler remove [find comment="${tag}"]`],
|
|
9110
|
-
["firewall rule", `/ip firewall filter remove [find comment="${tag}"]`],
|
|
9111
|
-
["dns sinkholes", `/ip dns static remove [find comment="${tag}"]`],
|
|
9112
|
-
["address-list", `/ip firewall address-list remove [find comment="${tag}"]`]
|
|
9113
|
-
];
|
|
9114
|
-
const cleared = [];
|
|
9115
|
-
for (const [label, cmd] of steps) {
|
|
9116
|
-
const r = await executeMikrotikCommand(cmd, ctx);
|
|
9117
|
-
if (looksLikeError(r))
|
|
9118
|
-
return `Failed removing ${label}: ${r} (cleared: ${cleared.join(", ") || "none"})`;
|
|
9119
|
-
cleared.push(label);
|
|
9120
|
-
}
|
|
9121
|
-
return `Policy '${a.name}' removed (${cleared.join(", ")}).`;
|
|
9122
|
-
}
|
|
9123
|
-
})
|
|
9124
|
-
];
|
|
9125
|
-
|
|
9126
|
-
// src/tools/dot1x-server.ts
|
|
9127
|
-
import { z as z23 } from "zod";
|
|
9128
|
-
var AuthTypes = z23.enum(["dot1x", "mac-auth", "dot1x,mac-auth"]);
|
|
9129
|
-
var MacAuthMode = z23.enum(["mac-as-username", "mac-as-username-and-password"]);
|
|
9130
|
-
var dot1xServerTools = [
|
|
9131
|
-
defineTool({
|
|
9132
|
-
name: "add_dot1x_server",
|
|
9133
|
-
title: "Add 802.1X Server (Authenticator) Entry",
|
|
9134
|
-
annotations: WRITE,
|
|
9135
|
-
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.
|
|
9136
|
-
|
|
9137
|
-
` + `Notes:
|
|
9138
|
-
` + ` auth_types: 'dot1x' (EAP supplicant), 'mac-auth' (MAC bypass), or
|
|
9139
|
-
` + ` 'dot1x,mac-auth' (both).
|
|
9140
|
-
` + ` guest_vlan_id / reject_vlan_id / server_fail_vlan_id: VLAN to assign
|
|
9141
|
-
` + ` when there is no supplicant, on auth failure, or when RADIUS is
|
|
9142
|
-
` + ` unreachable (number, or 'none').
|
|
9143
|
-
` + " interim_update: RADIUS interim-accounting update interval, e.g. '5m' or '0s'.",
|
|
9144
|
-
inputSchema: {
|
|
9145
|
-
interface: z23.string(),
|
|
9146
|
-
auth_types: AuthTypes.optional(),
|
|
9147
|
-
accounting: z23.boolean().optional(),
|
|
9148
|
-
interim_update: z23.string().optional().describe("e.g. '5m' or '0s'"),
|
|
9149
|
-
mac_auth_mode: MacAuthMode.optional(),
|
|
9150
|
-
guest_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
9151
|
-
reject_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
9152
|
-
server_fail_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
9153
|
-
reauth_timeout: z23.string().optional().describe("Re-auth period or 'none'"),
|
|
9154
|
-
comment: z23.string().optional(),
|
|
9155
|
-
disabled: z23.boolean().default(false)
|
|
9156
|
-
},
|
|
9157
|
-
async handler(a, ctx) {
|
|
9158
|
-
ctx.info(`Adding dot1x server: interface=${a.interface}`);
|
|
9159
|
-
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();
|
|
9160
|
-
const result = await executeMikrotikCommand(cmd, ctx);
|
|
9161
|
-
if (looksLikeError(result))
|
|
9162
|
-
return `Failed to add dot1x server: ${result}`;
|
|
9163
|
-
const details = await executeMikrotikCommand(`/interface dot1x server print detail where interface="${a.interface}"`, ctx);
|
|
9164
|
-
return details.trim() ? `Dot1x server added successfully:
|
|
9165
|
-
|
|
9166
|
-
${details}` : "Dot1x server addition completed but unable to verify.";
|
|
9167
|
-
}
|
|
9413
|
+
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.",
|
|
9414
|
+
inputSchema: { entry_id: z21.string() },
|
|
9415
|
+
handler: (a, ctx) => updateDnsStatic({ entry_id: a.entry_id, disabled: true }, ctx)
|
|
9168
9416
|
}),
|
|
9169
9417
|
defineTool({
|
|
9170
|
-
name: "
|
|
9171
|
-
title: "
|
|
9418
|
+
name: "get_dns_cache",
|
|
9419
|
+
title: "Get DNS Cache Entries",
|
|
9172
9420
|
annotations: READ,
|
|
9173
|
-
description: "Lists all
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
async handler(a, ctx) {
|
|
9179
|
-
ctx.info("Listing dot1x servers");
|
|
9180
|
-
const filters = [];
|
|
9181
|
-
if (a.interface_filter)
|
|
9182
|
-
filters.push(`interface="${a.interface_filter}"`);
|
|
9183
|
-
if (a.disabled_only)
|
|
9184
|
-
filters.push("disabled=yes");
|
|
9185
|
-
const result = await executeMikrotikCommand(`/interface dot1x server print${whereClause(filters)}`, ctx);
|
|
9186
|
-
return isEmpty(result) ? "No dot1x servers found matching the criteria." : `DOT1X SERVERS:
|
|
9421
|
+
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.",
|
|
9422
|
+
async handler(_a, ctx) {
|
|
9423
|
+
ctx.info("Getting DNS cache");
|
|
9424
|
+
const result = await executeMikrotikCommand("/ip dns cache print", ctx);
|
|
9425
|
+
return isEmpty(result) ? "DNS cache is empty." : `DNS CACHE:
|
|
9187
9426
|
|
|
9188
9427
|
${result}`;
|
|
9189
9428
|
}
|
|
9190
9429
|
}),
|
|
9191
9430
|
defineTool({
|
|
9192
|
-
name: "
|
|
9193
|
-
title: "
|
|
9194
|
-
annotations:
|
|
9195
|
-
description: "
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
|
|
9200
|
-
ctx.info(`Getting dot1x server: server_id=${a.server_id}`);
|
|
9201
|
-
let result = await executeMikrotikCommand(`/interface dot1x server print detail where .id="${a.server_id}"`, ctx);
|
|
9202
|
-
if (isEmpty(result)) {
|
|
9203
|
-
result = await executeMikrotikCommand(`/interface dot1x server print detail where interface="${a.server_id}"`, ctx);
|
|
9204
|
-
}
|
|
9205
|
-
return isEmpty(result) ? `Dot1x server '${a.server_id}' not found.` : `DOT1X SERVER DETAILS:
|
|
9206
|
-
|
|
9207
|
-
${result}`;
|
|
9431
|
+
name: "flush_dns_cache",
|
|
9432
|
+
title: "Flush DNS Cache",
|
|
9433
|
+
annotations: DESTRUCTIVE,
|
|
9434
|
+
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.",
|
|
9435
|
+
async handler(_a, ctx) {
|
|
9436
|
+
ctx.info("Flushing DNS cache");
|
|
9437
|
+
const result = await executeMikrotikCommand("/ip dns cache flush", ctx);
|
|
9438
|
+
return result.trim() ? `Flush result: ${result}` : "DNS cache flushed successfully.";
|
|
9208
9439
|
}
|
|
9209
9440
|
}),
|
|
9210
9441
|
defineTool({
|
|
9211
|
-
name: "
|
|
9212
|
-
title: "
|
|
9213
|
-
annotations:
|
|
9214
|
-
description: "
|
|
9215
|
-
|
|
9216
|
-
|
|
9217
|
-
|
|
9218
|
-
|
|
9219
|
-
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9225
|
-
|
|
9226
|
-
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
const selector = a.server_id.startsWith("*") ? `.id="${a.server_id}"` : `interface="${a.server_id}"`;
|
|
9231
|
-
const base = `/interface dot1x server set [find ${selector}]`;
|
|
9232
|
-
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);
|
|
9233
|
-
if (a.comment !== undefined)
|
|
9234
|
-
cmd.raw(`comment=${quoteValue(a.comment)}`);
|
|
9235
|
-
if (a.disabled !== undefined)
|
|
9236
|
-
cmd.raw(`disabled=${yesno(a.disabled)}`);
|
|
9237
|
-
const built = cmd.build();
|
|
9238
|
-
if (built === base)
|
|
9239
|
-
return "No updates specified.";
|
|
9240
|
-
const result = await executeMikrotikCommand(built, ctx);
|
|
9241
|
-
if (looksLikeError(result))
|
|
9242
|
-
return `Failed to update dot1x server: ${result}`;
|
|
9243
|
-
const details = await executeMikrotikCommand(`/interface dot1x server print detail where ${selector}`, ctx);
|
|
9244
|
-
return `Dot1x server updated successfully:
|
|
9442
|
+
name: "get_dns_cache_statistics",
|
|
9443
|
+
title: "Get DNS Cache Statistics",
|
|
9444
|
+
annotations: READ,
|
|
9445
|
+
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.",
|
|
9446
|
+
async handler(_a, ctx) {
|
|
9447
|
+
ctx.info("Getting DNS cache statistics");
|
|
9448
|
+
const settings = await executeMikrotikCommand("/ip dns print", ctx);
|
|
9449
|
+
if (looksLikeError(settings))
|
|
9450
|
+
return `Failed to get DNS cache statistics: ${settings}`;
|
|
9451
|
+
if (isEmpty(settings))
|
|
9452
|
+
return "Unable to retrieve DNS cache statistics.";
|
|
9453
|
+
const cacheLines = settings.split(`
|
|
9454
|
+
`).filter((l) => l.toLowerCase().includes("cache"));
|
|
9455
|
+
const stats = cacheLines.length ? cacheLines.join(`
|
|
9456
|
+
`) : settings.trim();
|
|
9457
|
+
const count = (await executeMikrotikCommand("/ip dns cache print count-only", ctx)).trim();
|
|
9458
|
+
const entryLine = /^\d+$/.test(count) ? `cached-entries: ${count}
|
|
9459
|
+
` : "";
|
|
9460
|
+
return `DNS CACHE STATISTICS:
|
|
9245
9461
|
|
|
9246
|
-
${
|
|
9462
|
+
${entryLine}${stats}`;
|
|
9247
9463
|
}
|
|
9248
9464
|
}),
|
|
9249
9465
|
defineTool({
|
|
9250
|
-
name: "
|
|
9251
|
-
title: "
|
|
9252
|
-
annotations: DESTRUCTIVE,
|
|
9253
|
-
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`.",
|
|
9254
|
-
inputSchema: {
|
|
9255
|
-
server_id: z23.string().describe("Interface name or RouterOS '.id'")
|
|
9256
|
-
},
|
|
9257
|
-
async handler(a, ctx) {
|
|
9258
|
-
ctx.info(`Removing dot1x server: server_id=${a.server_id}`);
|
|
9259
|
-
const selector = a.server_id.startsWith("*") ? `.id="${a.server_id}"` : `interface="${a.server_id}"`;
|
|
9260
|
-
const count = await executeMikrotikCommand(`/interface dot1x server print count-only where ${selector}`, ctx);
|
|
9261
|
-
if (count.trim() === "0")
|
|
9262
|
-
return `Dot1x server '${a.server_id}' not found.`;
|
|
9263
|
-
const result = await executeMikrotikCommand(`/interface dot1x server remove [find ${selector}]`, ctx);
|
|
9264
|
-
if (looksLikeError(result))
|
|
9265
|
-
return `Failed to remove dot1x server: ${result}`;
|
|
9266
|
-
return `Dot1x server '${a.server_id}' removed successfully.`;
|
|
9267
|
-
}
|
|
9268
|
-
})
|
|
9269
|
-
];
|
|
9270
|
-
|
|
9271
|
-
// src/tools/dot1x-client.ts
|
|
9272
|
-
import { z as z24 } from "zod";
|
|
9273
|
-
var dot1xClientTools = [
|
|
9274
|
-
defineTool({
|
|
9275
|
-
name: "add_dot1x_client",
|
|
9276
|
-
title: "Add 802.1X Supplicant Client",
|
|
9466
|
+
name: "add_dns_regexp",
|
|
9467
|
+
title: "Add DNS Regexp Static Record",
|
|
9277
9468
|
annotations: WRITE,
|
|
9278
|
-
description: "
|
|
9469
|
+
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`.",
|
|
9279
9470
|
inputSchema: {
|
|
9280
|
-
|
|
9281
|
-
|
|
9282
|
-
|
|
9283
|
-
|
|
9284
|
-
|
|
9285
|
-
password: z24.string().optional().describe("EAP password (password methods)"),
|
|
9286
|
-
comment: z24.string().optional(),
|
|
9287
|
-
disabled: z24.boolean().default(false)
|
|
9471
|
+
regexp: z21.string(),
|
|
9472
|
+
address: z21.string(),
|
|
9473
|
+
ttl: z21.string().default("1d"),
|
|
9474
|
+
comment: z21.string().optional(),
|
|
9475
|
+
disabled: z21.boolean().default(false)
|
|
9288
9476
|
},
|
|
9289
|
-
|
|
9290
|
-
|
|
9291
|
-
|
|
9292
|
-
|
|
9293
|
-
|
|
9294
|
-
|
|
9295
|
-
|
|
9296
|
-
|
|
9297
|
-
|
|
9298
|
-
${details}` : "Dot1x client addition completed but unable to verify.";
|
|
9299
|
-
}
|
|
9477
|
+
handler: (a, ctx) => addDnsStatic({
|
|
9478
|
+
name: "dummy",
|
|
9479
|
+
address: a.address,
|
|
9480
|
+
regexp: a.regexp,
|
|
9481
|
+
ttl: a.ttl,
|
|
9482
|
+
comment: a.comment,
|
|
9483
|
+
disabled: a.disabled
|
|
9484
|
+
}, ctx)
|
|
9300
9485
|
}),
|
|
9301
9486
|
defineTool({
|
|
9302
|
-
name: "
|
|
9303
|
-
title: "
|
|
9487
|
+
name: "test_dns_query",
|
|
9488
|
+
title: "Test DNS Resolution From Router",
|
|
9304
9489
|
annotations: READ,
|
|
9305
|
-
description: "
|
|
9490
|
+
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.",
|
|
9306
9491
|
inputSchema: {
|
|
9307
|
-
|
|
9308
|
-
|
|
9309
|
-
|
|
9492
|
+
name: z21.string(),
|
|
9493
|
+
server: z21.string().optional(),
|
|
9494
|
+
type: z21.string().default("A")
|
|
9310
9495
|
},
|
|
9311
9496
|
async handler(a, ctx) {
|
|
9312
|
-
ctx.info(
|
|
9313
|
-
|
|
9314
|
-
if (a.
|
|
9315
|
-
|
|
9316
|
-
if (a.
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
|
|
9320
|
-
const result = await executeMikrotikCommand(`/interface dot1x client print${whereClause(filters)}`, ctx);
|
|
9321
|
-
return isEmpty(result) ? "No dot1x clients found matching the criteria." : `DOT1X CLIENTS:
|
|
9497
|
+
ctx.info(`Testing DNS query: name=${a.name}, type=${a.type}`);
|
|
9498
|
+
let cmd = `/resolve ${a.name}`;
|
|
9499
|
+
if (a.server)
|
|
9500
|
+
cmd += ` server=${a.server}`;
|
|
9501
|
+
if (a.type !== "A")
|
|
9502
|
+
cmd += ` type=${a.type}`;
|
|
9503
|
+
const result = await executeMikrotikCommand(cmd, ctx);
|
|
9504
|
+
return isEmpty(result) ? `Failed to resolve ${a.name}` : `DNS QUERY RESULT for ${a.name}:
|
|
9322
9505
|
|
|
9323
9506
|
${result}`;
|
|
9324
9507
|
}
|
|
9325
9508
|
}),
|
|
9326
9509
|
defineTool({
|
|
9327
|
-
name: "
|
|
9328
|
-
title: "
|
|
9510
|
+
name: "export_dns_config",
|
|
9511
|
+
title: "Export DNS Configuration to File",
|
|
9329
9512
|
annotations: READ,
|
|
9330
|
-
description: "
|
|
9331
|
-
inputSchema: {
|
|
9332
|
-
client_id: z24.string().describe("Interface name (e.g. 'ether3') or RouterOS '.id'")
|
|
9333
|
-
},
|
|
9513
|
+
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.",
|
|
9514
|
+
inputSchema: { filename: z21.string().optional() },
|
|
9334
9515
|
async handler(a, ctx) {
|
|
9335
|
-
ctx.info(
|
|
9336
|
-
|
|
9337
|
-
|
|
9338
|
-
|
|
9339
|
-
}
|
|
9340
|
-
return isEmpty(result) ? `Dot1x client '${a.client_id}' not found.` : `DOT1X CLIENT DETAILS:
|
|
9341
|
-
|
|
9342
|
-
${result}`;
|
|
9516
|
+
ctx.info("Exporting DNS configuration");
|
|
9517
|
+
const filename = a.filename || "dns_config";
|
|
9518
|
+
const result = await executeMikrotikCommand(`/ip dns export file=${filename}`, ctx);
|
|
9519
|
+
return result.trim() ? `Export result: ${result}` : `DNS configuration exported to ${filename}.rsc`;
|
|
9343
9520
|
}
|
|
9344
|
-
})
|
|
9345
|
-
|
|
9346
|
-
|
|
9347
|
-
|
|
9348
|
-
|
|
9349
|
-
|
|
9521
|
+
})
|
|
9522
|
+
];
|
|
9523
|
+
|
|
9524
|
+
// src/tools/parental-controls.ts
|
|
9525
|
+
import { z as z22 } from "zod";
|
|
9526
|
+
function buildPolicyCommands(o) {
|
|
9527
|
+
const tag = `parental-${o.name}`;
|
|
9528
|
+
const groups = [];
|
|
9529
|
+
if (o.addresses?.length) {
|
|
9530
|
+
groups.push({
|
|
9531
|
+
label: "Target devices",
|
|
9532
|
+
commands: o.addresses.map((addr) => new Cmd("/ip firewall address-list add").set("list", o.list).set("address", addr).set("comment", tag).build())
|
|
9533
|
+
});
|
|
9534
|
+
}
|
|
9535
|
+
groups.push({
|
|
9536
|
+
label: "Scheduled internet cut-off",
|
|
9537
|
+
commands: [
|
|
9538
|
+
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(),
|
|
9539
|
+
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(),
|
|
9540
|
+
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()
|
|
9541
|
+
]
|
|
9542
|
+
});
|
|
9543
|
+
if (o.blockDomains?.length) {
|
|
9544
|
+
groups.push({
|
|
9545
|
+
label: "Content blocking (DNS sinkhole)",
|
|
9546
|
+
commands: o.blockDomains.map((d) => new Cmd("/ip dns static add").set("name", d).set("address", "0.0.0.0").set("comment", tag).build())
|
|
9547
|
+
});
|
|
9548
|
+
}
|
|
9549
|
+
return groups;
|
|
9550
|
+
}
|
|
9551
|
+
var parentalControlsTools = [
|
|
9552
|
+
defineTool({
|
|
9553
|
+
name: "set_time_policy",
|
|
9554
|
+
title: "Set Time-of-Day / Parental Policy",
|
|
9555
|
+
annotations: WRITE,
|
|
9556
|
+
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.",
|
|
9350
9557
|
inputSchema: {
|
|
9351
|
-
|
|
9352
|
-
|
|
9353
|
-
|
|
9354
|
-
|
|
9355
|
-
|
|
9356
|
-
|
|
9357
|
-
|
|
9358
|
-
disabled: z24.boolean().optional()
|
|
9558
|
+
name: z22.string().describe("Policy id, e.g. 'kids-bedtime'"),
|
|
9559
|
+
list: z22.string().default("parental-devices").describe("Address-list of the affected devices"),
|
|
9560
|
+
addresses: z22.array(z22.string()).optional().describe("Device IPs to add to the list"),
|
|
9561
|
+
block_start: z22.string().default("22:00").describe("Daily cut-off time (HH:MM)"),
|
|
9562
|
+
block_end: z22.string().default("07:00").describe("Daily restore time (HH:MM)"),
|
|
9563
|
+
block_domains: z22.array(z22.string()).optional().describe("Domains to always block via DNS"),
|
|
9564
|
+
apply: z22.boolean().default(false).describe("false = preview (default); true = install")
|
|
9359
9565
|
},
|
|
9360
9566
|
async handler(a, ctx) {
|
|
9361
|
-
|
|
9362
|
-
|
|
9363
|
-
|
|
9364
|
-
|
|
9365
|
-
|
|
9366
|
-
|
|
9367
|
-
|
|
9368
|
-
|
|
9369
|
-
const
|
|
9370
|
-
if (
|
|
9371
|
-
|
|
9372
|
-
|
|
9373
|
-
|
|
9374
|
-
return `Failed to update dot1x client: ${result}`;
|
|
9375
|
-
const details = await executeMikrotikCommand(`/interface dot1x client print detail where ${selector}`, ctx);
|
|
9376
|
-
return `Dot1x client updated successfully:
|
|
9567
|
+
const groups = buildPolicyCommands({
|
|
9568
|
+
name: a.name,
|
|
9569
|
+
list: a.list,
|
|
9570
|
+
addresses: a.addresses,
|
|
9571
|
+
blockStart: a.block_start,
|
|
9572
|
+
blockEnd: a.block_end,
|
|
9573
|
+
blockDomains: a.block_domains
|
|
9574
|
+
});
|
|
9575
|
+
const all = groups.flatMap((g) => g.commands);
|
|
9576
|
+
if (!a.apply) {
|
|
9577
|
+
const preview = groups.map((g) => `# ${g.label}
|
|
9578
|
+
${g.commands.map((c) => ` ${c}`).join(`
|
|
9579
|
+
`)}`).join(`
|
|
9377
9580
|
|
|
9378
|
-
|
|
9581
|
+
`);
|
|
9582
|
+
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):
|
|
9583
|
+
|
|
9584
|
+
${preview}`;
|
|
9585
|
+
}
|
|
9586
|
+
const done = [];
|
|
9587
|
+
for (const cmd of all) {
|
|
9588
|
+
const result = await executeMikrotikCommand(cmd, ctx);
|
|
9589
|
+
if (looksLikeError(result)) {
|
|
9590
|
+
return `Installed ${done.length}/${all.length}, then FAILED: ${result}`;
|
|
9591
|
+
}
|
|
9592
|
+
done.push(cmd);
|
|
9593
|
+
}
|
|
9594
|
+
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` : ""}.`;
|
|
9379
9595
|
}
|
|
9380
9596
|
}),
|
|
9381
9597
|
defineTool({
|
|
9382
|
-
name: "
|
|
9383
|
-
title: "Remove
|
|
9598
|
+
name: "remove_time_policy",
|
|
9599
|
+
title: "Remove Time-of-Day / Parental Policy",
|
|
9384
9600
|
annotations: DESTRUCTIVE,
|
|
9385
|
-
description: "Removes
|
|
9386
|
-
inputSchema: {
|
|
9387
|
-
client_id: z24.string().describe("Interface name or RouterOS '.id'")
|
|
9388
|
-
},
|
|
9601
|
+
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.",
|
|
9602
|
+
inputSchema: { name: z22.string().describe("The policy id used when installing") },
|
|
9389
9603
|
async handler(a, ctx) {
|
|
9390
|
-
|
|
9391
|
-
|
|
9392
|
-
const
|
|
9393
|
-
|
|
9394
|
-
|
|
9395
|
-
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
|
|
9604
|
+
const tag = `parental-${a.name}`;
|
|
9605
|
+
ctx.info(`Removing parental policy ${tag}`);
|
|
9606
|
+
const steps = [
|
|
9607
|
+
["schedulers", `/system scheduler remove [find comment="${tag}"]`],
|
|
9608
|
+
["firewall rule", `/ip firewall filter remove [find comment="${tag}"]`],
|
|
9609
|
+
["dns sinkholes", `/ip dns static remove [find comment="${tag}"]`],
|
|
9610
|
+
["address-list", `/ip firewall address-list remove [find comment="${tag}"]`]
|
|
9611
|
+
];
|
|
9612
|
+
const cleared = [];
|
|
9613
|
+
for (const [label, cmd] of steps) {
|
|
9614
|
+
const r = await executeMikrotikCommand(cmd, ctx);
|
|
9615
|
+
if (looksLikeError(r))
|
|
9616
|
+
return `Failed removing ${label}: ${r} (cleared: ${cleared.join(", ") || "none"})`;
|
|
9617
|
+
cleared.push(label);
|
|
9618
|
+
}
|
|
9619
|
+
return `Policy '${a.name}' removed (${cleared.join(", ")}).`;
|
|
9399
9620
|
}
|
|
9400
9621
|
})
|
|
9401
9622
|
];
|
|
9402
9623
|
|
|
9403
|
-
// src/tools/
|
|
9404
|
-
import { z as
|
|
9624
|
+
// src/tools/dot1x-server.ts
|
|
9625
|
+
import { z as z23 } from "zod";
|
|
9626
|
+
var AuthTypes = z23.enum(["dot1x", "mac-auth", "dot1x,mac-auth"]);
|
|
9627
|
+
var MacAuthMode = z23.enum(["mac-as-username", "mac-as-username-and-password"]);
|
|
9628
|
+
var dot1xServerTools = [
|
|
9629
|
+
defineTool({
|
|
9630
|
+
name: "add_dot1x_server",
|
|
9631
|
+
title: "Add 802.1X Server (Authenticator) Entry",
|
|
9632
|
+
annotations: WRITE,
|
|
9633
|
+
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.
|
|
9405
9634
|
|
|
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
|
-
"new-priority",
|
|
9437
|
-
"new-ttl",
|
|
9438
|
-
"jump-target"
|
|
9439
|
-
]);
|
|
9440
|
-
var NONDETERMINISTIC = new Set([
|
|
9441
|
-
"limit",
|
|
9442
|
-
"dst-limit",
|
|
9443
|
-
"random",
|
|
9444
|
-
"nth",
|
|
9445
|
-
"psd",
|
|
9446
|
-
"connection-bytes",
|
|
9447
|
-
"connection-rate",
|
|
9448
|
-
"rate",
|
|
9449
|
-
"time",
|
|
9450
|
-
"content",
|
|
9451
|
-
"layer7-protocol",
|
|
9452
|
-
"tls-host"
|
|
9453
|
-
]);
|
|
9454
|
-
var TERMINAL = new Set(["accept", "drop", "reject", "tarpit"]);
|
|
9455
|
-
var ADDRESS_KEYS = new Set(["src-address", "dst-address"]);
|
|
9456
|
-
var CATCH_ALL_ADDR = new Set(["0.0.0.0/0", "::/0"]);
|
|
9457
|
-
function rulesFromRows(rows) {
|
|
9458
|
-
return rows.map((r, i) => {
|
|
9459
|
-
const flags = r.flags ?? "";
|
|
9460
|
-
const match = {};
|
|
9461
|
-
const transform = {};
|
|
9462
|
-
for (const [k, v] of Object.entries(r)) {
|
|
9463
|
-
if (!v || NON_MATCH.has(k))
|
|
9464
|
-
continue;
|
|
9465
|
-
if (TRANSFORM_KEYS.has(k))
|
|
9466
|
-
transform[k] = v;
|
|
9467
|
-
else
|
|
9468
|
-
match[k] = v;
|
|
9469
|
-
}
|
|
9470
|
-
const num2 = (s) => {
|
|
9471
|
-
if (s == null)
|
|
9472
|
-
return;
|
|
9473
|
-
const n = Number(s.replace(/\s/g, ""));
|
|
9474
|
-
return Number.isFinite(n) ? n : undefined;
|
|
9475
|
-
};
|
|
9476
|
-
return {
|
|
9477
|
-
index: r["#"] != null && /^\d+$/.test(r["#"]) ? Number(r["#"]) : i,
|
|
9478
|
-
chain: r.chain ?? "?",
|
|
9479
|
-
action: r.action ?? "?",
|
|
9480
|
-
disabled: flags.includes("X"),
|
|
9481
|
-
dynamic: flags.includes("D"),
|
|
9482
|
-
comment: r.comment,
|
|
9483
|
-
packets: num2(r.packets),
|
|
9484
|
-
bytes: num2(r.bytes),
|
|
9485
|
-
match,
|
|
9486
|
-
transform,
|
|
9487
|
-
raw: r
|
|
9488
|
-
};
|
|
9489
|
-
});
|
|
9490
|
-
}
|
|
9491
|
-
function toCidr(value) {
|
|
9492
|
-
try {
|
|
9493
|
-
if (value.includes("/"))
|
|
9494
|
-
return ipaddr.parseCIDR(value);
|
|
9495
|
-
const addr = ipaddr.parse(value);
|
|
9496
|
-
return [addr, addr.kind() === "ipv6" ? 128 : 32];
|
|
9497
|
-
} catch {
|
|
9498
|
-
return null;
|
|
9499
|
-
}
|
|
9500
|
-
}
|
|
9501
|
-
function cidrContains(a, b) {
|
|
9502
|
-
const A = toCidr(a);
|
|
9503
|
-
const B = toCidr(b);
|
|
9504
|
-
if (!A || !B)
|
|
9505
|
-
return false;
|
|
9506
|
-
const [aAddr, aBits] = A;
|
|
9507
|
-
const [bAddr, bBits] = B;
|
|
9508
|
-
if (aAddr.kind() !== bAddr.kind())
|
|
9509
|
-
return false;
|
|
9510
|
-
if (aBits > bBits)
|
|
9511
|
-
return false;
|
|
9512
|
-
try {
|
|
9513
|
-
return bAddr.match(aAddr, aBits);
|
|
9514
|
-
} catch {
|
|
9515
|
-
return false;
|
|
9516
|
-
}
|
|
9517
|
-
}
|
|
9518
|
-
function covers(key, aVal, bVal) {
|
|
9519
|
-
if (aVal === bVal)
|
|
9520
|
-
return true;
|
|
9521
|
-
if (ADDRESS_KEYS.has(key))
|
|
9522
|
-
return cidrContains(aVal, bVal);
|
|
9523
|
-
return false;
|
|
9524
|
-
}
|
|
9525
|
-
var INTERFACE_LIST_PAIRS = [
|
|
9526
|
-
["in-interface-list", "in-interface"],
|
|
9527
|
-
["out-interface-list", "out-interface"]
|
|
9528
|
-
];
|
|
9529
|
-
var _ifaceLists;
|
|
9530
|
-
function interfaceListCovers(aKey, aVal, bKey, bVal) {
|
|
9531
|
-
if (!_ifaceLists)
|
|
9532
|
-
return;
|
|
9533
|
-
for (const [listKey, ifaceKey] of INTERFACE_LIST_PAIRS) {
|
|
9534
|
-
if (aKey === listKey && bKey === ifaceKey) {
|
|
9535
|
-
const negated = aVal.startsWith("!");
|
|
9536
|
-
const listName = negated ? aVal.slice(1) : aVal;
|
|
9537
|
-
const members = _ifaceLists.get(listName);
|
|
9538
|
-
if (!members)
|
|
9539
|
-
return;
|
|
9540
|
-
const isMember = members.has(bVal);
|
|
9541
|
-
return negated ? !isMember : isMember;
|
|
9635
|
+
` + `Notes:
|
|
9636
|
+
` + ` auth_types: 'dot1x' (EAP supplicant), 'mac-auth' (MAC bypass), or
|
|
9637
|
+
` + ` 'dot1x,mac-auth' (both).
|
|
9638
|
+
` + ` guest_vlan_id / reject_vlan_id / server_fail_vlan_id: VLAN to assign
|
|
9639
|
+
` + ` when there is no supplicant, on auth failure, or when RADIUS is
|
|
9640
|
+
` + ` unreachable (number, or 'none').
|
|
9641
|
+
` + " interim_update: RADIUS interim-accounting update interval, e.g. '5m' or '0s'.",
|
|
9642
|
+
inputSchema: {
|
|
9643
|
+
interface: z23.string(),
|
|
9644
|
+
auth_types: AuthTypes.optional(),
|
|
9645
|
+
accounting: z23.boolean().optional(),
|
|
9646
|
+
interim_update: z23.string().optional().describe("e.g. '5m' or '0s'"),
|
|
9647
|
+
mac_auth_mode: MacAuthMode.optional(),
|
|
9648
|
+
guest_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
9649
|
+
reject_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
9650
|
+
server_fail_vlan_id: z23.string().optional().describe("VLAN id or 'none'"),
|
|
9651
|
+
reauth_timeout: z23.string().optional().describe("Re-auth period or 'none'"),
|
|
9652
|
+
comment: z23.string().optional(),
|
|
9653
|
+
disabled: z23.boolean().default(false)
|
|
9654
|
+
},
|
|
9655
|
+
async handler(a, ctx) {
|
|
9656
|
+
ctx.info(`Adding dot1x server: interface=${a.interface}`);
|
|
9657
|
+
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();
|
|
9658
|
+
const result = await executeMikrotikCommand(cmd, ctx);
|
|
9659
|
+
if (looksLikeError(result))
|
|
9660
|
+
return `Failed to add dot1x server: ${result}`;
|
|
9661
|
+
const details = await executeMikrotikCommand(`/interface dot1x server print detail where interface="${a.interface}"`, ctx);
|
|
9662
|
+
return details.trim() ? `Dot1x server added successfully:
|
|
9663
|
+
|
|
9664
|
+
${details}` : "Dot1x server addition completed but unable to verify.";
|
|
9542
9665
|
}
|
|
9543
|
-
}
|
|
9544
|
-
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
|
|
9666
|
+
}),
|
|
9667
|
+
defineTool({
|
|
9668
|
+
name: "list_dot1x_servers",
|
|
9669
|
+
title: "List 802.1X Server (Authenticator) Entries",
|
|
9670
|
+
annotations: READ,
|
|
9671
|
+
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.",
|
|
9672
|
+
inputSchema: {
|
|
9673
|
+
interface_filter: z23.string().optional(),
|
|
9674
|
+
disabled_only: z23.boolean().default(false)
|
|
9675
|
+
},
|
|
9676
|
+
async handler(a, ctx) {
|
|
9677
|
+
ctx.info("Listing dot1x servers");
|
|
9678
|
+
const filters = [];
|
|
9679
|
+
if (a.interface_filter)
|
|
9680
|
+
filters.push(`interface="${a.interface_filter}"`);
|
|
9681
|
+
if (a.disabled_only)
|
|
9682
|
+
filters.push("disabled=yes");
|
|
9683
|
+
const result = await executeMikrotikCommand(`/interface dot1x server print${whereClause(filters)}`, ctx);
|
|
9684
|
+
return isEmpty(result) ? "No dot1x servers found matching the criteria." : `DOT1X SERVERS:
|
|
9685
|
+
|
|
9686
|
+
${result}`;
|
|
9553
9687
|
}
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
|
-
|
|
9557
|
-
|
|
9558
|
-
|
|
9559
|
-
|
|
9688
|
+
}),
|
|
9689
|
+
defineTool({
|
|
9690
|
+
name: "get_dot1x_server",
|
|
9691
|
+
title: "Get 802.1X Server (Authenticator) Entry Detail",
|
|
9692
|
+
annotations: READ,
|
|
9693
|
+
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.",
|
|
9694
|
+
inputSchema: {
|
|
9695
|
+
server_id: z23.string().describe("Interface name (e.g. 'ether2') or RouterOS '.id'")
|
|
9696
|
+
},
|
|
9697
|
+
async handler(a, ctx) {
|
|
9698
|
+
ctx.info(`Getting dot1x server: server_id=${a.server_id}`);
|
|
9699
|
+
let result = await executeMikrotikCommand(`/interface dot1x server print detail where .id="${a.server_id}"`, ctx);
|
|
9700
|
+
if (isEmpty(result)) {
|
|
9701
|
+
result = await executeMikrotikCommand(`/interface dot1x server print detail where interface="${a.server_id}"`, ctx);
|
|
9560
9702
|
}
|
|
9703
|
+
return isEmpty(result) ? `Dot1x server '${a.server_id}' not found.` : `DOT1X SERVER DETAILS:
|
|
9704
|
+
|
|
9705
|
+
${result}`;
|
|
9561
9706
|
}
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
9565
|
-
|
|
9566
|
-
|
|
9567
|
-
|
|
9568
|
-
|
|
9569
|
-
|
|
9570
|
-
|
|
9571
|
-
|
|
9572
|
-
|
|
9573
|
-
|
|
9574
|
-
|
|
9575
|
-
|
|
9576
|
-
|
|
9577
|
-
|
|
9578
|
-
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
|
|
9582
|
-
|
|
9583
|
-
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9587
|
-
|
|
9588
|
-
|
|
9589
|
-
|
|
9590
|
-
|
|
9591
|
-
|
|
9592
|
-
|
|
9593
|
-
|
|
9594
|
-
|
|
9595
|
-
}
|
|
9596
|
-
|
|
9597
|
-
|
|
9598
|
-
|
|
9599
|
-
}
|
|
9600
|
-
|
|
9601
|
-
|
|
9602
|
-
|
|
9603
|
-
|
|
9604
|
-
|
|
9605
|
-
|
|
9606
|
-
|
|
9607
|
-
|
|
9608
|
-
|
|
9609
|
-
|
|
9610
|
-
|
|
9611
|
-
|
|
9612
|
-
|
|
9613
|
-
|
|
9614
|
-
|
|
9615
|
-
|
|
9616
|
-
|
|
9617
|
-
|
|
9618
|
-
|
|
9619
|
-
|
|
9620
|
-
|
|
9621
|
-
|
|
9622
|
-
|
|
9623
|
-
|
|
9624
|
-
|
|
9625
|
-
|
|
9626
|
-
|
|
9627
|
-
|
|
9628
|
-
|
|
9629
|
-
|
|
9630
|
-
|
|
9631
|
-
|
|
9632
|
-
|
|
9633
|
-
|
|
9634
|
-
|
|
9635
|
-
|
|
9636
|
-
|
|
9637
|
-
|
|
9638
|
-
|
|
9639
|
-
|
|
9640
|
-
|
|
9641
|
-
|
|
9642
|
-
|
|
9643
|
-
|
|
9644
|
-
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
|
|
9648
|
-
|
|
9649
|
-
|
|
9650
|
-
|
|
9651
|
-
|
|
9652
|
-
|
|
9653
|
-
|
|
9707
|
+
}),
|
|
9708
|
+
defineTool({
|
|
9709
|
+
name: "update_dot1x_server",
|
|
9710
|
+
title: "Update 802.1X Server (Authenticator) Entry",
|
|
9711
|
+
annotations: WRITE_IDEMPOTENT,
|
|
9712
|
+
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.",
|
|
9713
|
+
inputSchema: {
|
|
9714
|
+
server_id: z23.string().describe("Interface name or RouterOS '.id'"),
|
|
9715
|
+
auth_types: AuthTypes.optional(),
|
|
9716
|
+
accounting: z23.boolean().optional(),
|
|
9717
|
+
interim_update: z23.string().optional(),
|
|
9718
|
+
mac_auth_mode: MacAuthMode.optional(),
|
|
9719
|
+
guest_vlan_id: z23.string().optional(),
|
|
9720
|
+
reject_vlan_id: z23.string().optional(),
|
|
9721
|
+
server_fail_vlan_id: z23.string().optional(),
|
|
9722
|
+
reauth_timeout: z23.string().optional(),
|
|
9723
|
+
comment: z23.string().optional(),
|
|
9724
|
+
disabled: z23.boolean().optional()
|
|
9725
|
+
},
|
|
9726
|
+
async handler(a, ctx) {
|
|
9727
|
+
ctx.info(`Updating dot1x server: server_id=${a.server_id}`);
|
|
9728
|
+
const selector = a.server_id.startsWith("*") ? `.id="${a.server_id}"` : `interface="${a.server_id}"`;
|
|
9729
|
+
const base = `/interface dot1x server set [find ${selector}]`;
|
|
9730
|
+
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);
|
|
9731
|
+
if (a.comment !== undefined)
|
|
9732
|
+
cmd.raw(`comment=${quoteValue(a.comment)}`);
|
|
9733
|
+
if (a.disabled !== undefined)
|
|
9734
|
+
cmd.raw(`disabled=${yesno(a.disabled)}`);
|
|
9735
|
+
const built = cmd.build();
|
|
9736
|
+
if (built === base)
|
|
9737
|
+
return "No updates specified.";
|
|
9738
|
+
const result = await executeMikrotikCommand(built, ctx);
|
|
9739
|
+
if (looksLikeError(result))
|
|
9740
|
+
return `Failed to update dot1x server: ${result}`;
|
|
9741
|
+
const details = await executeMikrotikCommand(`/interface dot1x server print detail where ${selector}`, ctx);
|
|
9742
|
+
return `Dot1x server updated successfully:
|
|
9743
|
+
|
|
9744
|
+
${details}`;
|
|
9745
|
+
}
|
|
9746
|
+
}),
|
|
9747
|
+
defineTool({
|
|
9748
|
+
name: "remove_dot1x_server",
|
|
9749
|
+
title: "Remove 802.1X Server (Authenticator) Entry",
|
|
9750
|
+
annotations: DESTRUCTIVE,
|
|
9751
|
+
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`.",
|
|
9752
|
+
inputSchema: {
|
|
9753
|
+
server_id: z23.string().describe("Interface name or RouterOS '.id'")
|
|
9754
|
+
},
|
|
9755
|
+
async handler(a, ctx) {
|
|
9756
|
+
ctx.info(`Removing dot1x server: server_id=${a.server_id}`);
|
|
9757
|
+
const selector = a.server_id.startsWith("*") ? `.id="${a.server_id}"` : `interface="${a.server_id}"`;
|
|
9758
|
+
const count = await executeMikrotikCommand(`/interface dot1x server print count-only where ${selector}`, ctx);
|
|
9759
|
+
if (count.trim() === "0")
|
|
9760
|
+
return `Dot1x server '${a.server_id}' not found.`;
|
|
9761
|
+
const result = await executeMikrotikCommand(`/interface dot1x server remove [find ${selector}]`, ctx);
|
|
9762
|
+
if (looksLikeError(result))
|
|
9763
|
+
return `Failed to remove dot1x server: ${result}`;
|
|
9764
|
+
return `Dot1x server '${a.server_id}' removed successfully.`;
|
|
9765
|
+
}
|
|
9766
|
+
})
|
|
9767
|
+
];
|
|
9768
|
+
|
|
9769
|
+
// src/tools/dot1x-client.ts
|
|
9770
|
+
import { z as z24 } from "zod";
|
|
9771
|
+
var dot1xClientTools = [
|
|
9772
|
+
defineTool({
|
|
9773
|
+
name: "add_dot1x_client",
|
|
9774
|
+
title: "Add 802.1X Supplicant Client",
|
|
9775
|
+
annotations: WRITE,
|
|
9776
|
+
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.",
|
|
9777
|
+
inputSchema: {
|
|
9778
|
+
interface: z24.string(),
|
|
9779
|
+
eap_methods: z24.string().describe("Comma-separated EAP methods, e.g. 'eap-tls' or 'eap-peap,eap-mschapv2'"),
|
|
9780
|
+
identity: z24.string().optional().describe("EAP identity (username)"),
|
|
9781
|
+
anonymous_identity: z24.string().optional(),
|
|
9782
|
+
certificate: z24.string().optional().describe("Client certificate name (required for eap-tls)"),
|
|
9783
|
+
password: z24.string().optional().describe("EAP password (password methods)"),
|
|
9784
|
+
comment: z24.string().optional(),
|
|
9785
|
+
disabled: z24.boolean().default(false)
|
|
9786
|
+
},
|
|
9787
|
+
async handler(a, ctx) {
|
|
9788
|
+
ctx.info(`Adding dot1x client: interface=${a.interface}`);
|
|
9789
|
+
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();
|
|
9790
|
+
const result = await executeMikrotikCommand(cmd, ctx);
|
|
9791
|
+
if (looksLikeError(result))
|
|
9792
|
+
return `Failed to add dot1x client: ${result}`;
|
|
9793
|
+
const details = await executeMikrotikCommand(`/interface dot1x client print detail where interface="${a.interface}"`, ctx);
|
|
9794
|
+
return details.trim() ? `Dot1x client added successfully:
|
|
9795
|
+
|
|
9796
|
+
${details}` : "Dot1x client addition completed but unable to verify.";
|
|
9797
|
+
}
|
|
9798
|
+
}),
|
|
9799
|
+
defineTool({
|
|
9800
|
+
name: "list_dot1x_clients",
|
|
9801
|
+
title: "List 802.1X Supplicant Clients",
|
|
9802
|
+
annotations: READ,
|
|
9803
|
+
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.",
|
|
9804
|
+
inputSchema: {
|
|
9805
|
+
interface_filter: z24.string().optional(),
|
|
9806
|
+
status_filter: z24.string().optional().describe("Match status, e.g. 'authenticated', 'authenticating'"),
|
|
9807
|
+
disabled_only: z24.boolean().default(false)
|
|
9808
|
+
},
|
|
9809
|
+
async handler(a, ctx) {
|
|
9810
|
+
ctx.info("Listing dot1x clients");
|
|
9811
|
+
const filters = [];
|
|
9812
|
+
if (a.interface_filter)
|
|
9813
|
+
filters.push(`interface="${a.interface_filter}"`);
|
|
9814
|
+
if (a.status_filter)
|
|
9815
|
+
filters.push(`status~"${a.status_filter}"`);
|
|
9816
|
+
if (a.disabled_only)
|
|
9817
|
+
filters.push("disabled=yes");
|
|
9818
|
+
const result = await executeMikrotikCommand(`/interface dot1x client print${whereClause(filters)}`, ctx);
|
|
9819
|
+
return isEmpty(result) ? "No dot1x clients found matching the criteria." : `DOT1X CLIENTS:
|
|
9820
|
+
|
|
9821
|
+
${result}`;
|
|
9654
9822
|
}
|
|
9655
|
-
|
|
9656
|
-
|
|
9657
|
-
|
|
9658
|
-
|
|
9659
|
-
|
|
9660
|
-
|
|
9661
|
-
|
|
9662
|
-
|
|
9663
|
-
|
|
9664
|
-
|
|
9665
|
-
|
|
9666
|
-
|
|
9823
|
+
}),
|
|
9824
|
+
defineTool({
|
|
9825
|
+
name: "get_dot1x_client",
|
|
9826
|
+
title: "Get 802.1X Supplicant Client Detail",
|
|
9827
|
+
annotations: READ,
|
|
9828
|
+
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.",
|
|
9829
|
+
inputSchema: {
|
|
9830
|
+
client_id: z24.string().describe("Interface name (e.g. 'ether3') or RouterOS '.id'")
|
|
9831
|
+
},
|
|
9832
|
+
async handler(a, ctx) {
|
|
9833
|
+
ctx.info(`Getting dot1x client: client_id=${a.client_id}`);
|
|
9834
|
+
let result = await executeMikrotikCommand(`/interface dot1x client print detail where .id="${a.client_id}"`, ctx);
|
|
9835
|
+
if (isEmpty(result)) {
|
|
9836
|
+
result = await executeMikrotikCommand(`/interface dot1x client print detail where interface="${a.client_id}"`, ctx);
|
|
9667
9837
|
}
|
|
9838
|
+
return isEmpty(result) ? `Dot1x client '${a.client_id}' not found.` : `DOT1X CLIENT DETAILS:
|
|
9839
|
+
|
|
9840
|
+
${result}`;
|
|
9668
9841
|
}
|
|
9669
|
-
}
|
|
9670
|
-
|
|
9671
|
-
|
|
9672
|
-
|
|
9673
|
-
|
|
9674
|
-
|
|
9675
|
-
|
|
9676
|
-
|
|
9677
|
-
|
|
9678
|
-
|
|
9679
|
-
|
|
9680
|
-
|
|
9681
|
-
|
|
9682
|
-
|
|
9683
|
-
|
|
9684
|
-
|
|
9685
|
-
|
|
9686
|
-
|
|
9687
|
-
|
|
9688
|
-
|
|
9689
|
-
|
|
9690
|
-
|
|
9691
|
-
|
|
9692
|
-
|
|
9693
|
-
|
|
9694
|
-
|
|
9695
|
-
|
|
9696
|
-
|
|
9697
|
-
|
|
9698
|
-
|
|
9699
|
-
|
|
9700
|
-
|
|
9701
|
-
|
|
9702
|
-
|
|
9703
|
-
|
|
9842
|
+
}),
|
|
9843
|
+
defineTool({
|
|
9844
|
+
name: "update_dot1x_client",
|
|
9845
|
+
title: "Update 802.1X Supplicant Client",
|
|
9846
|
+
annotations: WRITE_IDEMPOTENT,
|
|
9847
|
+
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.",
|
|
9848
|
+
inputSchema: {
|
|
9849
|
+
client_id: z24.string().describe("Interface name or RouterOS '.id'"),
|
|
9850
|
+
eap_methods: z24.string().optional(),
|
|
9851
|
+
identity: z24.string().optional(),
|
|
9852
|
+
anonymous_identity: z24.string().optional(),
|
|
9853
|
+
certificate: z24.string().optional(),
|
|
9854
|
+
password: z24.string().optional(),
|
|
9855
|
+
comment: z24.string().optional(),
|
|
9856
|
+
disabled: z24.boolean().optional()
|
|
9857
|
+
},
|
|
9858
|
+
async handler(a, ctx) {
|
|
9859
|
+
ctx.info(`Updating dot1x client: client_id=${a.client_id}`);
|
|
9860
|
+
const selector = a.client_id.startsWith("*") ? `.id="${a.client_id}"` : `interface="${a.client_id}"`;
|
|
9861
|
+
const base = `/interface dot1x client set [find ${selector}]`;
|
|
9862
|
+
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);
|
|
9863
|
+
if (a.comment !== undefined)
|
|
9864
|
+
cmd.raw(`comment=${quoteValue(a.comment)}`);
|
|
9865
|
+
if (a.disabled !== undefined)
|
|
9866
|
+
cmd.raw(`disabled=${yesno(a.disabled)}`);
|
|
9867
|
+
const built = cmd.build();
|
|
9868
|
+
if (built === base)
|
|
9869
|
+
return "No updates specified.";
|
|
9870
|
+
const result = await executeMikrotikCommand(built, ctx);
|
|
9871
|
+
if (looksLikeError(result))
|
|
9872
|
+
return `Failed to update dot1x client: ${result}`;
|
|
9873
|
+
const details = await executeMikrotikCommand(`/interface dot1x client print detail where ${selector}`, ctx);
|
|
9874
|
+
return `Dot1x client updated successfully:
|
|
9875
|
+
|
|
9876
|
+
${details}`;
|
|
9704
9877
|
}
|
|
9705
|
-
}
|
|
9706
|
-
|
|
9707
|
-
|
|
9708
|
-
|
|
9709
|
-
|
|
9710
|
-
|
|
9711
|
-
|
|
9712
|
-
|
|
9713
|
-
|
|
9714
|
-
|
|
9715
|
-
|
|
9716
|
-
|
|
9717
|
-
|
|
9718
|
-
|
|
9719
|
-
|
|
9720
|
-
|
|
9721
|
-
|
|
9878
|
+
}),
|
|
9879
|
+
defineTool({
|
|
9880
|
+
name: "remove_dot1x_client",
|
|
9881
|
+
title: "Remove 802.1X Supplicant Client",
|
|
9882
|
+
annotations: DESTRUCTIVE,
|
|
9883
|
+
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`.",
|
|
9884
|
+
inputSchema: {
|
|
9885
|
+
client_id: z24.string().describe("Interface name or RouterOS '.id'")
|
|
9886
|
+
},
|
|
9887
|
+
async handler(a, ctx) {
|
|
9888
|
+
ctx.info(`Removing dot1x client: client_id=${a.client_id}`);
|
|
9889
|
+
const selector = a.client_id.startsWith("*") ? `.id="${a.client_id}"` : `interface="${a.client_id}"`;
|
|
9890
|
+
const count = await executeMikrotikCommand(`/interface dot1x client print count-only where ${selector}`, ctx);
|
|
9891
|
+
if (count.trim() === "0")
|
|
9892
|
+
return `Dot1x client '${a.client_id}' not found.`;
|
|
9893
|
+
const result = await executeMikrotikCommand(`/interface dot1x client remove [find ${selector}]`, ctx);
|
|
9894
|
+
if (looksLikeError(result))
|
|
9895
|
+
return `Failed to remove dot1x client: ${result}`;
|
|
9896
|
+
return `Dot1x client '${a.client_id}' removed successfully.`;
|
|
9722
9897
|
}
|
|
9723
|
-
}
|
|
9724
|
-
|
|
9725
|
-
}
|
|
9726
|
-
var WEIGHT2 = { high: 20, medium: 8, low: 2 };
|
|
9727
|
-
function grade2(score) {
|
|
9728
|
-
if (score === 0)
|
|
9729
|
-
return "clean";
|
|
9730
|
-
if (score < 15)
|
|
9731
|
-
return "good";
|
|
9732
|
-
if (score < 40)
|
|
9733
|
-
return "fair";
|
|
9734
|
-
if (score < 75)
|
|
9735
|
-
return "poor";
|
|
9736
|
-
return "critical";
|
|
9737
|
-
}
|
|
9738
|
-
function auditFirewall(input) {
|
|
9739
|
-
_ifaceLists = input.interfaceLists;
|
|
9740
|
-
const findings = [];
|
|
9741
|
-
if (input.filter)
|
|
9742
|
-
findings.push(...auditFilter(input.filter));
|
|
9743
|
-
if (input.nat)
|
|
9744
|
-
findings.push(...auditTransform(input.nat, "nat"));
|
|
9745
|
-
if (input.mangle)
|
|
9746
|
-
findings.push(...auditTransform(input.mangle, "mangle"));
|
|
9747
|
-
const sevRank = { high: 0, medium: 1, low: 2 };
|
|
9748
|
-
findings.sort((a, b) => sevRank[a.severity] - sevRank[b.severity] || a.table.localeCompare(b.table) || (a.ruleIndex ?? -1) - (b.ruleIndex ?? -1));
|
|
9749
|
-
const counts = { high: 0, medium: 0, low: 0, total: findings.length };
|
|
9750
|
-
let raw = 0;
|
|
9751
|
-
for (const f of findings) {
|
|
9752
|
-
counts[f.severity]++;
|
|
9753
|
-
raw += WEIGHT2[f.severity];
|
|
9754
|
-
}
|
|
9755
|
-
const riskScore = Math.min(100, raw);
|
|
9756
|
-
const ruleCount = (input.filter?.length ?? 0) + (input.nat?.length ?? 0) + (input.mangle?.length ?? 0);
|
|
9757
|
-
return { riskScore, grade: grade2(riskScore), counts, ruleCount, findings };
|
|
9758
|
-
}
|
|
9759
|
-
function renderReport(report, device) {
|
|
9760
|
-
const head = `FIREWALL AUDIT \u2014 ${device}
|
|
9761
|
-
|
|
9762
|
-
` + `Risk score: ${report.riskScore}/100 (${report.grade})
|
|
9763
|
-
` + `${report.ruleCount} rule(s) analysed \xB7 ${report.counts.high} high, ${report.counts.medium} medium, ${report.counts.low} low
|
|
9764
|
-
`;
|
|
9765
|
-
if (report.findings.length === 0) {
|
|
9766
|
-
return `${head}
|
|
9767
|
-
No issues found \u2014 the ruleset looks clean. \u2713`;
|
|
9768
|
-
}
|
|
9769
|
-
const body = report.findings.map((f, i) => {
|
|
9770
|
-
const tag = f.severity.toUpperCase().padEnd(6);
|
|
9771
|
-
return `${i + 1}. [${tag}] ${f.title} (${f.table}/${f.chain})
|
|
9772
|
-
${f.detail}
|
|
9773
|
-
\u2192 ${f.suggestion}`;
|
|
9774
|
-
}).join(`
|
|
9775
|
-
|
|
9776
|
-
`);
|
|
9777
|
-
return `${head}
|
|
9778
|
-
${body}`;
|
|
9779
|
-
}
|
|
9898
|
+
})
|
|
9899
|
+
];
|
|
9780
9900
|
|
|
9781
9901
|
// src/tools/firewall-audit.ts
|
|
9902
|
+
import { z as z25 } from "zod";
|
|
9782
9903
|
async function fetchRules(path, ctx) {
|
|
9783
9904
|
const out = await executeMikrotikCommand(`${path} print detail`, ctx);
|
|
9784
9905
|
if (looksLikeError(out) || isEmpty(out))
|
|
@@ -11825,21 +11946,6 @@ function planPortScanDetection(state, args) {
|
|
|
11825
11946
|
}
|
|
11826
11947
|
|
|
11827
11948
|
// src/tools/port-scan-detection.ts
|
|
11828
|
-
async function fetchChainRules(chain, ctx) {
|
|
11829
|
-
const rows = await fetchRows(`/ip firewall filter print detail where chain=${chain}`, ctx);
|
|
11830
|
-
return rulesFromRows(rows);
|
|
11831
|
-
}
|
|
11832
|
-
async function addressListCount(list, ctx) {
|
|
11833
|
-
const raw = await executeMikrotikCommand(`/ip firewall address-list print count-only where list=${JSON.stringify(list)}`, ctx);
|
|
11834
|
-
const n = Number.parseInt(raw.trim(), 10);
|
|
11835
|
-
return Number.isFinite(n) ? n : 0;
|
|
11836
|
-
}
|
|
11837
|
-
async function inputChainIds(ctx) {
|
|
11838
|
-
const raw = await executeMikrotikCommand(":foreach i in=[/ip firewall filter find chain=input] do={:put $i}", ctx);
|
|
11839
|
-
if (isEmpty(raw))
|
|
11840
|
-
return [];
|
|
11841
|
-
return raw.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => /^\*[0-9A-Fa-f]+$/.test(l));
|
|
11842
|
-
}
|
|
11843
11949
|
var portScanDetectionTools = [
|
|
11844
11950
|
defineTool({
|
|
11845
11951
|
name: "list_port_scan_detection_signatures",
|
|
@@ -11849,7 +11955,7 @@ var portScanDetectionTools = [
|
|
|
11849
11955
|
async handler(_a, ctx) {
|
|
11850
11956
|
let present = null;
|
|
11851
11957
|
if (ctx.device !== undefined) {
|
|
11852
|
-
const detect = await
|
|
11958
|
+
const detect = await fetchFilterChainRules(DETECT_CHAIN, ctx);
|
|
11853
11959
|
present = new Set(PORT_SCAN_SIGNATURES.filter((s) => signaturePresent(detect, s)).map((s) => s.id));
|
|
11854
11960
|
}
|
|
11855
11961
|
const lines = [
|
|
@@ -11872,7 +11978,7 @@ var portScanDetectionTools = [
|
|
|
11872
11978
|
name: "add_port_scan_detection_rules",
|
|
11873
11979
|
title: "Add Port-Scan Detection Rules",
|
|
11874
11980
|
annotations: DANGEROUS,
|
|
11875
|
-
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
|
|
11981
|
+
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.",
|
|
11876
11982
|
inputSchema: {
|
|
11877
11983
|
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."),
|
|
11878
11984
|
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."),
|
|
@@ -11885,8 +11991,8 @@ var portScanDetectionTools = [
|
|
|
11885
11991
|
const device = resolveDeviceName(ctx.device);
|
|
11886
11992
|
ctx.info(`[${device}] add_port_scan_detection_rules: ${a.rule_types.join(",")}`);
|
|
11887
11993
|
const [inputRules, detectChainRules, trustListCount] = await Promise.all([
|
|
11888
|
-
|
|
11889
|
-
|
|
11994
|
+
fetchFilterChainRules("input", ctx),
|
|
11995
|
+
fetchFilterChainRules(DETECT_CHAIN, ctx),
|
|
11890
11996
|
addressListCount(a.trusted_list_name, ctx)
|
|
11891
11997
|
]);
|
|
11892
11998
|
const state = {
|
|
@@ -11911,61 +12017,30 @@ ${plan.error}`;
|
|
|
11911
12017
|
if (!plan.jump.present) {
|
|
11912
12018
|
let placeBeforeId;
|
|
11913
12019
|
if (plan.jump.placeBeforeIndex !== null) {
|
|
11914
|
-
const ids = await
|
|
12020
|
+
const ids = await filterChainRuleIds("input", ctx);
|
|
11915
12021
|
placeBeforeId = ids[plan.jump.placeBeforeIndex];
|
|
11916
12022
|
}
|
|
11917
12023
|
writeCommands.push(buildJumpCommand(a.trusted_list_name, placeBeforeId));
|
|
11918
12024
|
}
|
|
11919
|
-
const result = await
|
|
12025
|
+
const result = await applyWritesSafely(ctx, device, writeCommands, {
|
|
12026
|
+
allowDirectFallback: true
|
|
12027
|
+
});
|
|
11920
12028
|
return renderResult(a, plan, snapshotId, result, ctx);
|
|
11921
12029
|
}
|
|
11922
12030
|
})
|
|
11923
12031
|
];
|
|
11924
|
-
async function applyWrites(ctx, deviceName, commands) {
|
|
11925
|
-
if (commands.length === 0)
|
|
11926
|
-
return { applied: 0, safeMode: "not used (nothing to write)", committed: true };
|
|
11927
|
-
const useSafe = !getDevice(deviceName).mac;
|
|
11928
|
-
const mgr = getSafeModeManager(deviceName);
|
|
11929
|
-
if (useSafe) {
|
|
11930
|
-
const en = await mgr.enable();
|
|
11931
|
-
if (en.startsWith("Error"))
|
|
11932
|
-
return { applied: 0, safeMode: `failed to enable: ${en}`, committed: false };
|
|
11933
|
-
}
|
|
11934
|
-
let applied = 0;
|
|
11935
|
-
for (const cmd of commands) {
|
|
11936
|
-
const out = useSafe ? await mgr.execute(cmd).catch((e) => `error: ${String(e)}`) : await executeMikrotikCommand(cmd, ctx);
|
|
11937
|
-
if (looksLikeError(out) || out.startsWith("error:")) {
|
|
11938
|
-
if (useSafe)
|
|
11939
|
-
await mgr.rollback();
|
|
11940
|
-
return {
|
|
11941
|
-
applied,
|
|
11942
|
-
safeMode: useSafe ? "rolled back (a write failed \u2014 no changes kept)" : "not used",
|
|
11943
|
-
committed: false,
|
|
11944
|
-
error: out.trim().split(`
|
|
11945
|
-
`)[0]
|
|
11946
|
-
};
|
|
11947
|
-
}
|
|
11948
|
-
applied++;
|
|
11949
|
-
}
|
|
11950
|
-
if (useSafe) {
|
|
11951
|
-
const c = await mgr.commit();
|
|
11952
|
-
return {
|
|
11953
|
-
applied,
|
|
11954
|
-
safeMode: c.ok ? "committed" : `commit FAILED \u2014 changes revert: ${c.message}`,
|
|
11955
|
-
committed: c.ok
|
|
11956
|
-
};
|
|
11957
|
-
}
|
|
11958
|
-
return { applied, safeMode: "not used (MAC-Telnet device \u2014 no Safe Mode)", committed: true };
|
|
11959
|
-
}
|
|
11960
12032
|
async function renderResult(a, plan, snapshotId, outcome, ctx) {
|
|
11961
12033
|
const lines = [];
|
|
11962
12034
|
lines.push(`PORT-SCAN DETECTION \u2014 snapshot=${snapshotId} safe-mode=${outcome.safeMode}`);
|
|
11963
12035
|
if (outcome.error) {
|
|
11964
|
-
lines.push(`FAILED after ${outcome.applied} write(s): ${outcome.error}`);
|
|
11965
|
-
lines.push(
|
|
12036
|
+
lines.push(`FAILED after ${outcome.applied}/${outcome.total} write(s): ${outcome.error}`);
|
|
12037
|
+
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.");
|
|
11966
12038
|
return lines.join(`
|
|
11967
12039
|
`);
|
|
11968
12040
|
}
|
|
12041
|
+
if (outcome.fellBack) {
|
|
12042
|
+
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).`);
|
|
12043
|
+
}
|
|
11969
12044
|
const created = plan.signatures.filter((s) => s.status === "create");
|
|
11970
12045
|
const existing = plan.signatures.filter((s) => s.status === "already_present");
|
|
11971
12046
|
lines.push("");
|
|
@@ -12199,9 +12274,9 @@ var firewallFilterTools = [
|
|
|
12199
12274
|
name: "create_filter_rule",
|
|
12200
12275
|
title: "Create Firewall Filter Rule",
|
|
12201
12276
|
annotations: WRITE,
|
|
12202
|
-
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".',
|
|
12277
|
+
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".',
|
|
12203
12278
|
inputSchema: {
|
|
12204
|
-
chain: z30.
|
|
12279
|
+
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."),
|
|
12205
12280
|
action: z30.enum([
|
|
12206
12281
|
"accept",
|
|
12207
12282
|
"drop",
|
|
@@ -12211,7 +12286,9 @@ var firewallFilterTools = [
|
|
|
12211
12286
|
"passthrough",
|
|
12212
12287
|
"return",
|
|
12213
12288
|
"tarpit",
|
|
12214
|
-
"fasttrack-connection"
|
|
12289
|
+
"fasttrack-connection",
|
|
12290
|
+
"add-src-to-address-list",
|
|
12291
|
+
"add-dst-to-address-list"
|
|
12215
12292
|
]),
|
|
12216
12293
|
src_address: z30.string().optional(),
|
|
12217
12294
|
dst_address: z30.string().optional(),
|