mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:43:17 +08:00
feat(ne-exec): make max CLI commands configurable and allow traceroute
Add NETX_NE_EXEC_MAX_COMMANDS (default 5, cap 50) and whitelist traceroute/tracert/trace/trace6 prefixes for lab/ops use. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a6a5abc9ef
commit
88479245bb
9 changed files with 116 additions and 15 deletions
|
|
@ -3,6 +3,8 @@ NETX_DATABASE_URL=postgresql+psycopg://netx:netx@127.0.0.1:5432/netx
|
|||
NETX_CREDENTIAL_SECRET_KEY=
|
||||
NETX_NE_CONNECT_MAX_WORKERS=5
|
||||
NETX_NE_CONNECT_TIMEOUT_SEC=30
|
||||
# Managed NE CLI: max commands per exec request (default 5, hard cap 50). Lab can set 10+.
|
||||
NETX_NE_EXEC_MAX_COMMANDS=5
|
||||
NETX_HOST=127.0.0.1
|
||||
NETX_PORT=8890
|
||||
NETX_VENDOR=ZTE
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
| `NETX_API_URL` | 否 | `http://127.0.0.1:8890` | netx REST 根地址,可指向远端 |
|
||||
| `NETX_API_TOKEN` | 否 | 空 | API 启用 Bearer 时填写 |
|
||||
| `NETX_LANG` | 否 | `zh` | `zh` / `en`,影响 API 文案 |
|
||||
| `NETX_NE_EXEC_MAX_COMMANDS` | 否 | `5` | `execManagedNe` 单次最多命令数(硬上限 50);API 与 MCP 需同设 |
|
||||
|
||||
本机默认端口时 **可不设任何变量**。
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ class Settings(BaseSettings):
|
|||
ne_collect_pending_stale_sec: int = 180
|
||||
ne_collect_run_timeout_cap_sec: int = 600
|
||||
ne_collection_data_dir: str = "data/ne_collections"
|
||||
# Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec).
|
||||
ne_exec_max_commands: int = 5
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ def api_delete_ume_synced_managed_ne(db: Session = Depends(get_db)):
|
|||
|
||||
@router.post("/exec")
|
||||
def api_exec_managed_ne(body: ManagedNeExecRequest, db: Session = Depends(get_db)):
|
||||
"""Login to a managed NE or UME inventory NE and run read-only CLI (show/display/ping)."""
|
||||
"""Login to a managed NE or UME inventory NE and run read-only CLI (show/display/ping/traceroute)."""
|
||||
return execute_managed_ne_commands(
|
||||
db,
|
||||
body.commands,
|
||||
|
|
|
|||
|
|
@ -13,11 +13,16 @@ from .config import settings
|
|||
from .ne_collect_runner import _collect_on_device
|
||||
from .ne_crypto import credentials_configured
|
||||
|
||||
_EXEC_MAX_COMMANDS = 5
|
||||
_EXEC_MAX_COMMANDS_CAP = 50
|
||||
_EXEC_MAX_OUTPUT = 32_000
|
||||
_EXEC_READ_TIMEOUT_DEFAULT = 60
|
||||
_EXEC_READ_TIMEOUT_MAX = 120
|
||||
|
||||
|
||||
def _exec_max_commands() -> int:
|
||||
raw = int(settings.ne_exec_max_commands or 5)
|
||||
return max(1, min(_EXEC_MAX_COMMANDS_CAP, raw))
|
||||
|
||||
# Block obvious config-change / destructive patterns (case-insensitive).
|
||||
_BLOCKED_RE = re.compile(
|
||||
r"(?i)("
|
||||
|
|
@ -29,8 +34,10 @@ _BLOCKED_RE = re.compile(
|
|||
r")"
|
||||
)
|
||||
|
||||
# Read-only CLI: show/display plus ping reachability checks.
|
||||
_ALLOWED_PREFIX_RE = re.compile(r"(?i)^(show\s|display\s|ping\s|ping6\s)")
|
||||
# Read-only CLI: show/display plus ping/traceroute reachability checks.
|
||||
_ALLOWED_PREFIX_RE = re.compile(
|
||||
r"(?i)^(show\s|display\s|ping\s|ping6\s|traceroute\s|tracert\s|trace\s|trace6\s)"
|
||||
)
|
||||
|
||||
# Unicode / C1 line separators that can smuggle a second CLI after a show prefix.
|
||||
_FORBIDDEN_LINE_SEPARATORS = ("\u2028", "\u2029", "\x85", "\x0b", "\x0c")
|
||||
|
|
@ -94,8 +101,9 @@ def execute_managed_ne_commands(
|
|||
cmds = [str(c).strip() for c in commands if str(c).strip()]
|
||||
if not cmds:
|
||||
raise HTTPException(status_code=400, detail="commands_required")
|
||||
if len(cmds) > _EXEC_MAX_COMMANDS:
|
||||
raise HTTPException(status_code=400, detail=f"too_many_commands (max {_EXEC_MAX_COMMANDS})")
|
||||
max_cmds = _exec_max_commands()
|
||||
if len(cmds) > max_cmds:
|
||||
raise HTTPException(status_code=400, detail=f"too_many_commands (max {max_cmds})")
|
||||
for c in cmds:
|
||||
_validate_command(c)
|
||||
|
||||
|
|
|
|||
|
|
@ -117,7 +117,8 @@ class ManagedNeExecRequest(BaseModel):
|
|||
|
||||
ne_id: str | None = None
|
||||
ume_ne_id: str | None = None
|
||||
commands: list[str] = Field(min_length=1, max_length=5)
|
||||
# Absolute ceiling; runtime limit is settings.ne_exec_max_commands (default 5).
|
||||
commands: list[str] = Field(min_length=1, max_length=50)
|
||||
read_timeout_sec: int | None = Field(default=None, ge=10, le=120)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,24 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
from .http_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id
|
||||
|
||||
_EXEC_MAX_COMMANDS_CAP = 50
|
||||
_EXEC_MAX_COMMANDS_DEFAULT = 5
|
||||
|
||||
|
||||
def exec_max_commands() -> int:
|
||||
"""Mirror netx API NETX_NE_EXEC_MAX_COMMANDS (default 5, hard cap 50)."""
|
||||
try:
|
||||
raw = int(os.getenv("NETX_NE_EXEC_MAX_COMMANDS") or _EXEC_MAX_COMMANDS_DEFAULT)
|
||||
except ValueError:
|
||||
raw = _EXEC_MAX_COMMANDS_DEFAULT
|
||||
return max(1, min(_EXEC_MAX_COMMANDS_CAP, raw))
|
||||
|
||||
|
||||
UME_RAW_GROUP_FIELDS = [
|
||||
"alarm_alarm_key",
|
||||
"alarm_host_name",
|
||||
|
|
@ -241,7 +255,7 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
commands = [str(c).strip() for c in raw_cmds if str(c).strip()]
|
||||
if not commands:
|
||||
return {"ok": False, "error": "commands_required", "error_code": "commands_required"}
|
||||
if len(commands) > 5:
|
||||
if len(commands) > exec_max_commands():
|
||||
return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"}
|
||||
body: dict[str, Any] = {"commands": commands}
|
||||
if ne_id:
|
||||
|
|
@ -418,13 +432,22 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
{
|
||||
"name": "execManagedNe",
|
||||
"description": "Run read-only CLI via netx (show/display/ping; max 5 commands). Use ne_id (managed NE) OR ume_ne_id (UME inventory).",
|
||||
"description": (
|
||||
f"Run read-only CLI via netx (show/display/ping/traceroute; "
|
||||
f"max {exec_max_commands()} commands, NETX_NE_EXEC_MAX_COMMANDS). "
|
||||
"Use ne_id (managed NE) OR ume_ne_id (UME inventory)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ne_id": {"type": "string"},
|
||||
"ume_ne_id": {"type": "string"},
|
||||
"commands": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 5},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"minItems": 1,
|
||||
"maxItems": exec_max_commands(),
|
||||
},
|
||||
"read_timeout_sec": {"type": "integer", "minimum": 10, "maximum": 120},
|
||||
},
|
||||
"required": ["commands"],
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ import pytest
|
|||
from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool
|
||||
|
||||
|
||||
def test_http_mcp_tool_list_has_twelve_tools() -> None:
|
||||
def test_http_mcp_tool_list_has_expected_tools() -> None:
|
||||
names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS]
|
||||
assert len(names) == 12
|
||||
assert len(names) == 13
|
||||
assert "queryUmeAlarms" in names
|
||||
assert "queryUmeAlarmsRaw" in names
|
||||
assert "execManagedNe" in names
|
||||
assert "listCliTargets" in names
|
||||
exec_tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "execManagedNe")
|
||||
assert exec_tool["inputSchema"]["properties"]["commands"]["maxItems"] >= 5
|
||||
|
||||
|
||||
def test_call_query_ume_alarms_forwards_http() -> None:
|
||||
|
|
@ -59,6 +62,25 @@ def test_call_exec_managed_ne_posts_body() -> None:
|
|||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_call_exec_managed_ne_respects_max_commands_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("NETX_NE_EXEC_MAX_COMMANDS", "10")
|
||||
cmds = [f"show version {i}" for i in range(6)]
|
||||
with patch("netx_mcp.http_tools.http_post_json") as mock_post:
|
||||
mock_post.return_value = {"ok": True, "data": {"ok": True, "output": "ok"}}
|
||||
out = call_http_tool("execManagedNe", {"ne_id": "abc", "commands": cmds})
|
||||
mock_post.assert_called_once()
|
||||
text = out["content"][0]["text"]
|
||||
payload = json.loads(text)
|
||||
assert payload["ok"] is True
|
||||
|
||||
monkeypatch.setenv("NETX_NE_EXEC_MAX_COMMANDS", "5")
|
||||
out = call_http_tool("execManagedNe", {"ne_id": "abc", "commands": cmds})
|
||||
text = out["content"][0]["text"]
|
||||
payload = json.loads(text)
|
||||
assert payload.get("ok") is False
|
||||
assert payload.get("error_code") == "too_many_commands"
|
||||
|
||||
|
||||
def test_stdio_initialize_and_tools_list() -> None:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "netx_mcp"],
|
||||
|
|
@ -81,7 +103,7 @@ def test_stdio_initialize_and_tools_list() -> None:
|
|||
list_line = proc.stdout.readline()
|
||||
list_resp = json.loads(list_line)
|
||||
tools = list_resp["result"]["tools"]
|
||||
assert len(tools) == 12
|
||||
assert len(tools) == 13
|
||||
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
|
|
|
|||
|
|
@ -20,11 +20,16 @@ class NeExecValidationTests(unittest.TestCase):
|
|||
_validate_command("ping6 2001::1")
|
||||
_validate_command("PING 10.0.0.1 vrf MGMT")
|
||||
|
||||
def test_allows_traceroute(self) -> None:
|
||||
_validate_command("traceroute 192.168.0.1")
|
||||
_validate_command("tracert 192.168.0.1")
|
||||
_validate_command("trace 10.0.0.1")
|
||||
_validate_command("trace6 2001::1")
|
||||
_validate_command("TRACEROUTE 10.0.0.1 vpn-instance MGMT")
|
||||
|
||||
def test_blocks_non_allowed_prefix(self) -> None:
|
||||
for cmd in (
|
||||
"get system info",
|
||||
"traceroute 192.168.0.1",
|
||||
"tracert 192.168.0.1",
|
||||
"terminal length 0",
|
||||
"?",
|
||||
):
|
||||
|
|
@ -156,6 +161,43 @@ class NeExecRunTests(unittest.TestCase):
|
|||
collect.assert_not_called()
|
||||
resolve.assert_not_called()
|
||||
|
||||
@patch("netx_api.ne_exec.credentials_configured", return_value=True)
|
||||
@patch("netx_api.ne_exec._collect_on_device", return_value="ok-output")
|
||||
@patch("netx_api.ne_exec.resolve_cli_target")
|
||||
def test_execute_respects_max_commands_setting(self, resolve, collect, _configured) -> None:
|
||||
db = MagicMock()
|
||||
cmds = [f"show version {i}" for i in range(6)]
|
||||
with patch("netx_api.ne_exec.settings") as mock_settings:
|
||||
mock_settings.ne_exec_max_commands = 5
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
execute_managed_ne_commands(db, cmds, ne_id="ne-1")
|
||||
self.assertIn("too_many_commands", str(ctx.exception.detail))
|
||||
collect.assert_not_called()
|
||||
|
||||
resolve.return_value = (
|
||||
{"ip_address": "1.1.1.1"},
|
||||
{
|
||||
"source": "managed",
|
||||
"id": "ne-1",
|
||||
"ume_ne_id": None,
|
||||
"name": "R2",
|
||||
"vendor": "Cisco",
|
||||
"device_type": "cisco_ios",
|
||||
"ip_address": "192.168.0.128",
|
||||
"port": 22,
|
||||
"protocol": "ssh",
|
||||
"connect_status": "pass",
|
||||
"hop_enabled": False,
|
||||
"hop_vendor": "zte",
|
||||
},
|
||||
)
|
||||
with patch("netx_api.ne_exec.settings") as mock_settings:
|
||||
mock_settings.ne_exec_max_commands = 10
|
||||
mock_settings.ne_collect_read_timeout_sec = 120
|
||||
out = execute_managed_ne_commands(db, cmds, ne_id="ne-1")
|
||||
self.assertTrue(out["ok"])
|
||||
collect.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue