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:
oliver 2026-07-28 10:57:55 +08:00
parent a6a5abc9ef
commit 88479245bb
9 changed files with 116 additions and 15 deletions

View file

@ -3,6 +3,8 @@ NETX_DATABASE_URL=postgresql+psycopg://netx:netx@127.0.0.1:5432/netx
NETX_CREDENTIAL_SECRET_KEY= NETX_CREDENTIAL_SECRET_KEY=
NETX_NE_CONNECT_MAX_WORKERS=5 NETX_NE_CONNECT_MAX_WORKERS=5
NETX_NE_CONNECT_TIMEOUT_SEC=30 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_HOST=127.0.0.1
NETX_PORT=8890 NETX_PORT=8890
NETX_VENDOR=ZTE NETX_VENDOR=ZTE

View file

@ -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_URL` | 否 | `http://127.0.0.1:8890` | netx REST 根地址,可指向远端 |
| `NETX_API_TOKEN` | 否 | 空 | API 启用 Bearer 时填写 | | `NETX_API_TOKEN` | 否 | 空 | API 启用 Bearer 时填写 |
| `NETX_LANG` | 否 | `zh` | `zh` / `en`,影响 API 文案 | | `NETX_LANG` | 否 | `zh` | `zh` / `en`,影响 API 文案 |
| `NETX_NE_EXEC_MAX_COMMANDS` | 否 | `5` | `execManagedNe` 单次最多命令数(硬上限 50);API 与 MCP 需同设 |
本机默认端口时 **可不设任何变量**。 本机默认端口时 **可不设任何变量**。

View file

@ -72,6 +72,8 @@ class Settings(BaseSettings):
ne_collect_pending_stale_sec: int = 180 ne_collect_pending_stale_sec: int = 180
ne_collect_run_timeout_cap_sec: int = 600 ne_collect_run_timeout_cap_sec: int = 600
ne_collection_data_dir: str = "data/ne_collections" 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() settings = Settings()

View file

@ -128,7 +128,7 @@ def api_delete_ume_synced_managed_ne(db: Session = Depends(get_db)):
@router.post("/exec") @router.post("/exec")
def api_exec_managed_ne(body: ManagedNeExecRequest, db: Session = Depends(get_db)): 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( return execute_managed_ne_commands(
db, db,
body.commands, body.commands,

View file

@ -13,11 +13,16 @@ from .config import settings
from .ne_collect_runner import _collect_on_device from .ne_collect_runner import _collect_on_device
from .ne_crypto import credentials_configured from .ne_crypto import credentials_configured
_EXEC_MAX_COMMANDS = 5 _EXEC_MAX_COMMANDS_CAP = 50
_EXEC_MAX_OUTPUT = 32_000 _EXEC_MAX_OUTPUT = 32_000
_EXEC_READ_TIMEOUT_DEFAULT = 60 _EXEC_READ_TIMEOUT_DEFAULT = 60
_EXEC_READ_TIMEOUT_MAX = 120 _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). # Block obvious config-change / destructive patterns (case-insensitive).
_BLOCKED_RE = re.compile( _BLOCKED_RE = re.compile(
r"(?i)(" r"(?i)("
@ -29,8 +34,10 @@ _BLOCKED_RE = re.compile(
r")" r")"
) )
# Read-only CLI: show/display plus ping reachability checks. # 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)") _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. # Unicode / C1 line separators that can smuggle a second CLI after a show prefix.
_FORBIDDEN_LINE_SEPARATORS = ("\u2028", "\u2029", "\x85", "\x0b", "\x0c") _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()] cmds = [str(c).strip() for c in commands if str(c).strip()]
if not cmds: if not cmds:
raise HTTPException(status_code=400, detail="commands_required") raise HTTPException(status_code=400, detail="commands_required")
if len(cmds) > _EXEC_MAX_COMMANDS: max_cmds = _exec_max_commands()
raise HTTPException(status_code=400, detail=f"too_many_commands (max {_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: for c in cmds:
_validate_command(c) _validate_command(c)

View file

@ -117,7 +117,8 @@ class ManagedNeExecRequest(BaseModel):
ne_id: str | None = None ne_id: str | None = None
ume_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) read_timeout_sec: int | None = Field(default=None, ge=10, le=120)

View file

@ -2,10 +2,24 @@
from __future__ import annotations from __future__ import annotations
import os
from typing import Any, Callable from typing import Any, Callable
from .http_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id 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 = [ UME_RAW_GROUP_FIELDS = [
"alarm_alarm_key", "alarm_alarm_key",
"alarm_host_name", "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()] commands = [str(c).strip() for c in raw_cmds if str(c).strip()]
if not commands: if not commands:
return {"ok": False, "error": "commands_required", "error_code": "commands_required"} 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"} return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"}
body: dict[str, Any] = {"commands": commands} body: dict[str, Any] = {"commands": commands}
if ne_id: if ne_id:
@ -418,13 +432,22 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
}, },
{ {
"name": "execManagedNe", "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": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"ne_id": {"type": "string"}, "ne_id": {"type": "string"},
"ume_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}, "read_timeout_sec": {"type": "integer", "minimum": 10, "maximum": 120},
}, },
"required": ["commands"], "required": ["commands"],

View file

@ -12,12 +12,15 @@ import pytest
from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool 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] 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 "queryUmeAlarms" in names
assert "queryUmeAlarmsRaw" in names assert "queryUmeAlarmsRaw" in names
assert "execManagedNe" 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: 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 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: def test_stdio_initialize_and_tools_list() -> None:
proc = subprocess.Popen( proc = subprocess.Popen(
[sys.executable, "-m", "netx_mcp"], [sys.executable, "-m", "netx_mcp"],
@ -81,7 +103,7 @@ def test_stdio_initialize_and_tools_list() -> None:
list_line = proc.stdout.readline() list_line = proc.stdout.readline()
list_resp = json.loads(list_line) list_resp = json.loads(list_line)
tools = list_resp["result"]["tools"] tools = list_resp["result"]["tools"]
assert len(tools) == 12 assert len(tools) == 13
proc.terminate() proc.terminate()
proc.wait(timeout=5) proc.wait(timeout=5)

View file

@ -20,11 +20,16 @@ class NeExecValidationTests(unittest.TestCase):
_validate_command("ping6 2001::1") _validate_command("ping6 2001::1")
_validate_command("PING 10.0.0.1 vrf MGMT") _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: def test_blocks_non_allowed_prefix(self) -> None:
for cmd in ( for cmd in (
"get system info", "get system info",
"traceroute 192.168.0.1",
"tracert 192.168.0.1",
"terminal length 0", "terminal length 0",
"?", "?",
): ):
@ -156,6 +161,43 @@ class NeExecRunTests(unittest.TestCase):
collect.assert_not_called() collect.assert_not_called()
resolve.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__": if __name__ == "__main__":
unittest.main() unittest.main()