Add NE capability descriptors and native async exec jobs for agents.

Expose recommended_mode/hints on getManagedNe, support background exec-jobs with getNeExecJob in MCP, and allow linux_shell on MikroTik as well as Linux.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-06 18:32:14 +08:00
parent c6a98e2526
commit 8bdaaaacbf
20 changed files with 928 additions and 88 deletions

View file

@ -71,6 +71,9 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
| `NETX_MCP_TOKEN_FILE` | 否 | `data/auth/mcp_token` | 默认 token 文件路径 |
| `NETX_LANG` | 否 | `zh` | `zh` / `en`,影响 API 文案 |
| `NETX_NE_EXEC_MAX_COMMANDS` | 否 | `5` | `execManagedNe` 单次最多命令数(硬上限 50);API 与 MCP 需同设 |
| `NETX_NE_EXEC_ASYNC_MIN_NES` | 否 | `4` | 批量 NE 数 ≥ 此值时自动走后台 job(`0`=仅 `async=true`) |
| `NETX_NE_EXEC_JOB_DIR` | 否 | `data/ne_exec_jobs` | 后台 exec job 状态目录 |
| `NETX_NE_EXEC_JOB_MAX_CONCURRENT` | 否 | `3` | 同时运行的 exec job 上限 |
本机默认端口时 **可不设任何变量**。启用登录后,先启动一次 netx API,会生成 `data/auth/mcp_token`;MCP 会自动带上该 token。若要把 token 写进 Cursor 配置:
@ -136,7 +139,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
| NMS 告警 | `queryNmsAlarms`, `aggregateNmsAlarms`, `runNmsDiagnostics` |
| NMS 网元 | `queryNmsNeInventory`, `getNmsNe` |
| NMS 原始/SQL | `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms` |
| common(CLI + 路径) | `listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets`, `findTopologyPaths` |
| common(CLI + 路径) | `listManagedNe`, `getManagedNe`, `execManagedNe`, `getNeExecJob`, `listCliTargets`, `findTopologyPaths` |
参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。拓扑 Fabric / 画布工具在 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**。oclaw 中名称带前缀:`mcp__netx__<toolName>`;DSH:`netx__<toolName>`。

View file

@ -118,6 +118,8 @@ def required_scope_for_request(method: str, path: str) -> str | None:
if p.startswith("/v1/managed-ne"):
path_tail = p.rstrip("/")
if "/exec-jobs" in p:
return SCOPE_NE_EXEC
if m == "POST" and (path_tail.endswith("/exec") or path_tail.endswith("/exec-batch")):
return SCOPE_NE_EXEC
if m in ("POST", "PUT", "PATCH", "DELETE"):

View file

@ -146,6 +146,12 @@ class Settings(BaseSettings):
ne_exec_max_commands: int = 5
# Opt-in: allow per-NE exec_policy (linux_shell/unrestricted). Default off — UI hidden.
ne_exec_policy_enabled: bool = False
# Background exec jobs (MCP getNeExecJob / POST /v1/managed-ne/exec-jobs).
ne_exec_job_dir: str = "data/ne_exec_jobs"
ne_exec_job_max_concurrent: int = 3
ne_exec_job_timeout_s: int = 900
# Auto-async when batch NE count >= this (0 = only when async=true).
ne_exec_async_min_nes: int = 4
# WebCRT interactive terminal sessions (multi-operator concurrent terminals).
webcrt_max_sessions: int = 40
# Per-user cap (0 = unlimited beyond global max).

View file

@ -12,13 +12,16 @@ from .db import get_db
from .device_types import SUPPORTED_VENDORS
from .ne_connect import schedule_connect_tests
from .ne_crypto import credentials_configured
from .ne_capability import build_ne_capability
from .ne_exec import execute_managed_ne_commands, execute_managed_ne_commands_batch
from .ne_exec_jobs import get_ne_exec_job, start_ne_exec_job
from .ne_schemas import (
BatchAccountApplyRequest,
BatchHopApplyRequest,
ConnectTestRequest,
ManagedNeCreate,
ManagedNeExecBatchRequest,
ManagedNeExecJobCreate,
ManagedNeExecRequest,
ManagedNeUpdate,
)
@ -149,6 +152,39 @@ def api_delete_ume_synced_managed_ne(db: Session = Depends(get_db)):
return delete_ume_synced_managed_ne(db).model_dump()
def _audit_exec(
db: Session,
*,
uid: str,
uname: str,
path: str,
out: dict,
body_ne_id: str = "",
body_ume_ne_id: str = "",
commands: list | None = None,
) -> None:
device = out.get("device") if isinstance(out.get("device"), dict) else {}
write_audit(
db,
action="ne.exec",
actor_user_id=uid,
actor_username=uname,
method="POST",
path=path,
status_code=200 if out.get("ok") else 502,
detail={
"ne_id": body_ne_id,
"ume_ne_id": body_ume_ne_id,
"ne_name": str(device.get("name") or device.get("ne_name") or ""),
"ne_ip": str(device.get("ip_address") or device.get("ip") or device.get("mgmt_ip") or ""),
"commands": list(out.get("commands") or commands or [])[:20],
"ok": bool(out.get("ok")),
"error": str(out.get("error") or "")[:500],
"output_len": len(str(out.get("output") or "")),
},
)
@router.post("/exec")
def api_exec_managed_ne(
body: ManagedNeExecRequest,
@ -164,25 +200,15 @@ def api_exec_managed_ne(
ume_ne_id=body.ume_ne_id,
read_timeout_sec=body.read_timeout_sec,
)
device = out.get("device") if isinstance(out.get("device"), dict) else {}
write_audit(
_audit_exec(
db,
action="ne.exec",
actor_user_id=uid,
actor_username=uname,
method="POST",
uid=uid,
uname=uname,
path="/v1/managed-ne/exec",
status_code=200 if out.get("ok") else 502,
detail={
"ne_id": body.ne_id or "",
"ume_ne_id": body.ume_ne_id or "",
"ne_name": str(device.get("name") or device.get("ne_name") or ""),
"ne_ip": str(device.get("ip_address") or device.get("ip") or device.get("mgmt_ip") or ""),
"commands": list(out.get("commands") or body.commands or [])[:20],
"ok": bool(out.get("ok")),
"error": str(out.get("error") or "")[:500],
"output_len": len(str(out.get("output") or "")),
},
out=out,
body_ne_id=body.ne_id or "",
body_ume_ne_id=body.ume_ne_id or "",
commands=list(body.commands or []),
)
return out
@ -235,6 +261,96 @@ def api_exec_managed_ne_batch(
return out
@router.post("/exec-jobs")
def api_create_exec_job(
body: ManagedNeExecJobCreate,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
):
"""Start a background exec (single or batch). Poll GET /exec-jobs/{job_id}."""
from .db import SessionLocal
uid, uname = _actor(ctx)
payload = body.model_dump(exclude_none=True)
is_batch = bool(
payload.get("targets")
or payload.get("ne_ids")
or payload.get("ume_ne_ids")
)
kind = "exec-batch" if is_batch else "exec"
def _runner() -> dict:
with SessionLocal() as job_db:
if is_batch:
targets = payload.get("targets")
out = execute_managed_ne_commands_batch(
targets=targets,
ne_ids=payload.get("ne_ids"),
ume_ne_ids=payload.get("ume_ne_ids"),
commands=payload.get("commands"),
read_timeout_sec=payload.get("read_timeout_sec"),
concurrency=payload.get("concurrency"),
)
write_audit(
job_db,
action="ne.exec_batch",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-jobs",
status_code=200,
detail={
"async": True,
"ne_ids": list(payload.get("ne_ids") or [])[:100],
"ume_ne_ids": list(payload.get("ume_ne_ids") or [])[:100],
"ok": bool(out.get("ok", True)),
},
)
return out
cmds = list(payload.get("commands") or [])
out = execute_managed_ne_commands(
job_db,
cmds,
ne_id=payload.get("ne_id"),
ume_ne_id=payload.get("ume_ne_id"),
read_timeout_sec=payload.get("read_timeout_sec"),
)
_audit_exec(
job_db,
uid=uid,
uname=uname,
path="/v1/managed-ne/exec-jobs",
out=out,
body_ne_id=str(payload.get("ne_id") or ""),
body_ume_ne_id=str(payload.get("ume_ne_id") or ""),
commands=cmds,
)
return out
ack = start_ne_exec_job(kind=kind, arguments=payload, runner=_runner)
write_audit(
db,
action="ne.exec_job_start",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-jobs",
status_code=200 if ack.get("ok") else 429,
detail={"job_id": ack.get("job_id") or "", "kind": kind, "ok": bool(ack.get("ok"))},
)
return ack
@router.get("/exec-jobs/{job_id}")
def api_get_exec_job(
job_id: str,
ctx: Annotated[AuthContext, Depends(require_user)],
):
"""Poll a background exec job started via POST /exec-jobs."""
_ = ctx
return get_ne_exec_job(job_id)
@router.post("/connect-test")
def api_connect_test(body: ConnectTestRequest, db: Session = Depends(get_db)):
ids = [str(x).strip() for x in body.ids if str(x).strip()]
@ -251,7 +367,14 @@ def api_connect_test(body: ConnectTestRequest, db: Session = Depends(get_db)):
@router.get("/{ne_id}")
def api_get_managed_ne(ne_id: str, db: Session = Depends(get_db)):
return get_managed_ne(db, ne_id).model_dump()
out = get_managed_ne(db, ne_id).model_dump()
out["capability"] = build_ne_capability(
device_type=out.get("device_type"),
exec_policy=out.get("exec_policy"),
vendor=out.get("vendor"),
hop_enabled=bool(out.get("hop_enabled")),
)
return out
@router.patch("/{ne_id}")

110
netx_api/ne_capability.py Normal file
View file

@ -0,0 +1,110 @@
"""Agent-facing capability descriptor for managed NE exec.
Derived from device_type + exec_policy + runtime settings — not stored in DB.
Callers (MCP / UI) should read this before choosing short CLI vs on-device script vs async job.
"""
from __future__ import annotations
from typing import Any
from .ne_exec import _EXEC_BATCH_MAX_TARGETS, _EXEC_READ_TIMEOUT_DEFAULT, _EXEC_READ_TIMEOUT_MAX
from .ne_exec_guard import (
EXEC_POLICY_LINUX_SHELL,
EXEC_POLICY_READONLY,
EXEC_POLICY_UNRESTRICTED,
_LINUX_SHELL_MAX_LEN,
_LINUX_SHELL_MAX_LINES,
_READONLY_MAX_LEN,
allows_open_exec_policy,
effective_exec_policy,
is_linux_device_type,
is_mikrotik_device_type,
normalize_exec_policy,
)
def _device_family(device_type: str | None) -> str:
if is_linux_device_type(device_type):
return "linux"
if is_mikrotik_device_type(device_type):
return "mikrotik"
return "network_cli"
def _max_commands() -> int:
from .config import settings
from .ne_exec import _EXEC_MAX_COMMANDS_CAP
raw = int(getattr(settings, "ne_exec_max_commands", 5) or 5)
return max(1, min(_EXEC_MAX_COMMANDS_CAP, raw))
def build_ne_capability(
*,
device_type: str | None,
exec_policy: str | None,
vendor: str | None = None,
hop_enabled: bool = False,
) -> dict[str, Any]:
"""Return a stable capability object for agents and ops UIs."""
stored = normalize_exec_policy(exec_policy)
effective = effective_exec_policy(exec_policy, device_type=device_type)
family = _device_family(device_type)
shell_ok = effective in (EXEC_POLICY_LINUX_SHELL, EXEC_POLICY_UNRESTRICTED)
open_eligible = allows_open_exec_policy(device_type)
max_cmds = _max_commands()
if effective == EXEC_POLICY_READONLY:
recommended = "show_only"
hints = [
"readonly: only show/display/ping/traceroute (optional pipe filters).",
"For shell/scripts set exec_policy=linux_shell on a Linux or MikroTik NE "
"(requires NETX_NE_EXEC_POLICY_ENABLED).",
]
elif family == "linux":
recommended = "script_on_device"
hints = [
"Prefer short on-device scripts for multi-step work; avoid huge inline heredocs in MCP args.",
"Long / multi-NE work: execManagedNe async=true then poll getNeExecJob.",
f"Max {max_cmds} commands per call; combine steps in one script when needed.",
]
elif family == "mikrotik":
recommended = "short_cli"
hints = [
"Prefer short RouterOS lines (comment=/find=); avoid one ultra-long line that may truncate.",
"Multiline scripts allowed under linux_shell; keep each logical change small and idempotent.",
"Long / multi-NE work: execManagedNe async=true then poll getNeExecJob.",
]
else:
recommended = "show_only"
hints = ["network_cli: use vendor show/display; write changes are blocked under readonly."]
if hop_enabled:
hints.append("hop_enabled: expect higher latency; raise read_timeout_sec toward 90–120 if needed.")
return {
"vendor": str(vendor or "").strip() or None,
"device_type": str(device_type or "").strip() or None,
"device_family": family,
"exec_policy_stored": stored,
"exec_policy_effective": effective,
"allows_shell_scripts": bool(shell_ok),
"allows_multiline": bool(shell_ok),
"open_policy_eligible": bool(open_eligible),
"max_commands_per_call": max_cmds,
"max_command_len": _LINUX_SHELL_MAX_LEN if shell_ok else _READONLY_MAX_LEN,
"max_command_lines": _LINUX_SHELL_MAX_LINES if shell_ok else 1,
"read_timeout_sec": {
"default": _EXEC_READ_TIMEOUT_DEFAULT,
"max": _EXEC_READ_TIMEOUT_MAX,
},
"supports_batch_exec": True,
"batch_max_targets": _EXEC_BATCH_MAX_TARGETS,
"supports_async_job": True,
"recommended_mode": recommended,
"hints": hints,
}
__all__ = ["build_ne_capability"]

View file

@ -3,7 +3,9 @@
Policies (per managed NE ``exec_policy``):
- ``readonly`` (default): network CLI only — show/display/ping/traceroute.
- ``linux_shell``: single-line shell; no network prefix/pipe rules; no write-deny list.
- ``linux_shell``: open shell/script on capable NEs (Linux, MikroTik RouterOS) —
pipes/&&/;/quotes/heredoc / RouterOS multiline scripts allowed;
no network prefix/pipe rules; no write-deny list.
- ``unrestricted``: same as linux_shell (lab open); kept distinct for audit/UI.
"""
@ -20,6 +22,11 @@ EXEC_POLICIES = frozenset(
{EXEC_POLICY_READONLY, EXEC_POLICY_LINUX_SHELL, EXEC_POLICY_UNRESTRICTED}
)
# readonly: short show/ping lines. linux_shell: scripts / heredoc file writes for agents.
_READONLY_MAX_LEN = 500
_LINUX_SHELL_MAX_LEN = 65_536
_LINUX_SHELL_MAX_LINES = 2_000
# Block obvious config-change / destructive patterns (case-insensitive).
_BLOCKED_RE = re.compile(
r"(?i)("
@ -63,6 +70,16 @@ def is_linux_device_type(device_type: str | None) -> bool:
return low in ("linux", "linux_ssh", "linux_telnet") or low.startswith("linux_")
def is_mikrotik_device_type(device_type: str | None) -> bool:
low = str(device_type or "").strip().lower()
return low in ("mikrotik_routeros", "mikrotik_switchos") or low.startswith("mikrotik_")
def allows_open_exec_policy(device_type: str | None) -> bool:
"""Device types that may use linux_shell / unrestricted (multiline scripts)."""
return is_linux_device_type(device_type) or is_mikrotik_device_type(device_type)
def exec_policy_feature_enabled() -> bool:
"""Global kill-switch: off → always readonly (UI hidden, API rejects open policies)."""
from .config import settings
@ -71,11 +88,11 @@ def exec_policy_feature_enabled() -> bool:
def effective_exec_policy(raw: str | None, *, device_type: str | None = None) -> str:
"""Policy used at exec time (forces readonly when feature off or non-linux)."""
"""Policy used at exec time (forces readonly when feature off or ineligible type)."""
if not exec_policy_feature_enabled():
return EXEC_POLICY_READONLY
pol = normalize_exec_policy(raw)
if pol != EXEC_POLICY_READONLY and not is_linux_device_type(device_type):
if pol != EXEC_POLICY_READONLY and not allows_open_exec_policy(device_type):
return EXEC_POLICY_READONLY
return pol
@ -85,14 +102,14 @@ def require_exec_policy_writable(
*,
device_type: str | None = None,
) -> str:
"""Normalize for create/update; reject open policies when feature off or non-linux."""
"""Normalize for create/update; reject open policies when feature off or ineligible type."""
pol = normalize_exec_policy(raw)
if pol == EXEC_POLICY_READONLY:
return pol
if not exec_policy_feature_enabled():
raise HTTPException(status_code=400, detail="exec_policy_feature_disabled")
if not is_linux_device_type(device_type):
raise HTTPException(status_code=400, detail="exec_policy_requires_linux_device_type")
if not allows_open_exec_policy(device_type):
raise HTTPException(status_code=400, detail="exec_policy_requires_shell_device_type")
return pol
@ -109,11 +126,14 @@ def _validate_pipe_segments(cmd: str) -> None:
raise HTTPException(status_code=400, detail="command_pipe_not_allowed")
def _validate_single_line(cmd: str) -> None:
if any(ch in cmd for ch in ("\n", "\r")):
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
def _validate_linux_shell_command(cmd: str) -> None:
"""Shell policy: allow multiline (heredoc / scripts); still reject exotic separators."""
if any(sep in cmd for sep in _FORBIDDEN_LINE_SEPARATORS):
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
# Count lines after normalizing CRLF; blank trailing newline from strip() is already gone.
line_count = cmd.count("\n") + 1
if line_count > _LINUX_SHELL_MAX_LINES:
raise HTTPException(status_code=400, detail="command_too_many_lines")
def _validate_readonly_command(cmd: str) -> None:
@ -133,12 +153,17 @@ def validate_ne_exec_command(command: str, *, policy: str = EXEC_POLICY_READONLY
cmd = str(command or "").strip()
if not cmd:
raise HTTPException(status_code=400, detail="empty_command")
if len(cmd) > 500:
raise HTTPException(status_code=400, detail="command_too_long")
pol = normalize_exec_policy(policy)
max_len = (
_LINUX_SHELL_MAX_LEN
if pol in (EXEC_POLICY_LINUX_SHELL, EXEC_POLICY_UNRESTRICTED)
else _READONLY_MAX_LEN
)
if len(cmd) > max_len:
raise HTTPException(status_code=400, detail="command_too_long")
if pol in (EXEC_POLICY_LINUX_SHELL, EXEC_POLICY_UNRESTRICTED):
# One command string per slot; shell metacharacters (|;&&`$) allowed.
_validate_single_line(cmd)
# Shell metacharacters (|;&&`$'"<<) and newlines (heredoc) allowed.
_validate_linux_shell_command(cmd)
return
_validate_readonly_command(cmd)

297
netx_api/ne_exec_jobs.py Normal file
View file

@ -0,0 +1,297 @@
"""Background jobs for managed-NE exec (NetX-native; Linux + RouterOS + batch).
Agents poll GET /v1/managed-ne/exec-jobs/{job_id} (MCP: getNeExecJob).
Sync exec stays on POST /exec and /exec-batch; long or multi-NE work should use jobs.
"""
from __future__ import annotations
import json
import os
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Callable
_STATUS_RUNNING = "running"
_STATUS_SUCCEEDED = "succeeded"
_STATUS_FAILED = "failed"
_STATUS_TIMEOUT = "timeout"
_TERMINAL = {_STATUS_SUCCEEDED, _STATUS_FAILED, _STATUS_TIMEOUT}
_LOCK = threading.Lock()
_INFLIGHT = 0
_DEFAULT_TIMEOUT_S = 900
_DEFAULT_MAX_CONCURRENT = 3
_DEFAULT_ASYNC_MIN_NES = 4
def _env_int(name: str, default: int, *, min_v: int, max_v: int) -> int:
raw = str(os.getenv(name) or "").strip()
if not raw:
try:
from .config import settings
attr = name.lower()
# Map common env → settings when present.
mapping = {
"NETX_NE_EXEC_JOB_MAX_CONCURRENT": "ne_exec_job_max_concurrent",
"NETX_NE_EXEC_ASYNC_MIN_NES": "ne_exec_async_min_nes",
"NETX_NE_EXEC_JOB_TIMEOUT_S": "ne_exec_job_timeout_s",
}
key = mapping.get(name)
if key and hasattr(settings, key):
return max(min_v, min(int(getattr(settings, key)), max_v))
except Exception:
pass
return default
try:
n = int(raw)
except Exception:
return default
return max(min_v, min(int(n), max_v))
def _jobs_dir() -> Path:
override = str(os.getenv("NETX_NE_EXEC_JOB_DIR") or "").strip()
if override:
p = Path(override).expanduser().resolve()
else:
try:
from .config import settings
raw = str(getattr(settings, "ne_exec_job_dir", "") or "").strip()
if raw:
p = Path(raw).expanduser().resolve()
else:
p = Path("data/ne_exec_jobs").resolve()
except Exception:
p = Path("data/ne_exec_jobs").resolve()
p.mkdir(parents=True, exist_ok=True)
return p
def _job_path(job_id: str) -> Path:
return _jobs_dir() / f"{job_id}.json"
def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, default=str), encoding="utf-8")
tmp.replace(path)
def _read_job(job_id: str) -> dict[str, Any] | None:
path = _job_path(job_id)
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return data if isinstance(data, dict) else None
def _write_job(job: dict[str, Any]) -> None:
jid = str(job.get("job_id") or "").strip()
if not jid:
return
_atomic_write(_job_path(jid), job)
def async_min_ne_count() -> int:
"""Auto-async when batch NE count >= this (0 disables auto; async=true still works)."""
return _env_int("NETX_NE_EXEC_ASYNC_MIN_NES", _DEFAULT_ASYNC_MIN_NES, min_v=0, max_v=50)
def max_concurrent_jobs() -> int:
return _env_int("NETX_NE_EXEC_JOB_MAX_CONCURRENT", _DEFAULT_MAX_CONCURRENT, min_v=1, max_v=16)
def default_job_timeout_s() -> int:
return _env_int("NETX_NE_EXEC_JOB_TIMEOUT_S", _DEFAULT_TIMEOUT_S, min_v=60, max_v=1800)
def count_exec_ne_targets(args: dict[str, Any] | None) -> int:
a = args if isinstance(args, dict) else {}
n = 0
for key in ("ne_ids", "ume_ne_ids", "nms_ne_ids"):
val = a.get(key)
if isinstance(val, list):
n = max(n, len([x for x in val if str(x or "").strip()]))
targets = a.get("targets")
if isinstance(targets, list):
n = max(n, len([t for t in targets if isinstance(t, dict)]))
if n == 0 and (
str(a.get("ne_id") or "").strip()
or str(a.get("ume_ne_id") or "").strip()
or str(a.get("nms_ne_id") or "").strip()
):
return 1
return int(n)
def _truthy_async_flag(raw: Any) -> bool | None:
if raw is None:
return None
if isinstance(raw, bool):
return raw
text = str(raw).strip().lower()
if not text:
return None
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return None
def should_run_exec_async(args: dict[str, Any] | None) -> bool:
a = dict(args or {})
flag = _truthy_async_flag(a.get("async"))
if flag is False:
return False
if flag is True:
return True
min_n = async_min_ne_count()
if min_n <= 0:
return False
return count_exec_ne_targets(a) >= int(min_n)
def start_ne_exec_job(
*,
kind: str,
arguments: dict[str, Any],
runner: Callable[[], dict[str, Any]],
timeout_s: int | None = None,
) -> dict[str, Any]:
"""Start background runner; returns immediate ack with job_id."""
global _INFLIGHT
max_c = max_concurrent_jobs()
with _LOCK:
if _INFLIGHT >= max_c:
return {
"ok": False,
"error_code": "ne_exec_job_busy",
"error": "ne_exec_job_busy",
"hint": (
f"Too many concurrent NE exec jobs (max {max_c}). "
"Poll getNeExecJob for running jobs or shrink the batch."
),
}
_INFLIGHT += 1
job_id = uuid.uuid4().hex
timeout = int(timeout_s) if timeout_s is not None else default_job_timeout_s()
timeout = max(60, min(timeout, 1800))
now = int(time.time() * 1000)
job = {
"job_id": job_id,
"status": _STATUS_RUNNING,
"kind": str(kind or "exec"),
"arguments": dict(arguments or {}),
"ne_count": count_exec_ne_targets(arguments),
"created_at_ms": now,
"updated_at_ms": now,
"timeout_s": timeout,
"result": None,
"error": "",
}
_write_job(job)
def _worker() -> None:
global _INFLIGHT
started = time.time()
try:
result = runner()
if not isinstance(result, dict):
result = {
"ok": False,
"error": "invalid_runner_result",
"payload_type": type(result).__name__,
}
status = _STATUS_SUCCEEDED if result.get("ok") is not False else _STATUS_FAILED
if time.time() - started > timeout:
status = _STATUS_TIMEOUT
cur = _read_job(job_id) or job
cur.update(
{
"status": status,
"updated_at_ms": int(time.time() * 1000),
"result": result,
"error": str(result.get("error") or "") if status != _STATUS_SUCCEEDED else "",
"duration_ms": int((time.time() - started) * 1000),
}
)
_write_job(cur)
except Exception as exc:
cur = _read_job(job_id) or job
cur.update(
{
"status": _STATUS_FAILED,
"updated_at_ms": int(time.time() * 1000),
"error": f"{type(exc).__name__}: {exc}",
"result": {"ok": False, "error": f"{type(exc).__name__}: {exc}"},
"duration_ms": int((time.time() - started) * 1000),
}
)
_write_job(cur)
finally:
with _LOCK:
_INFLIGHT = max(0, _INFLIGHT - 1)
threading.Thread(target=_worker, name=f"ne-exec-{job_id[:8]}", daemon=True).start()
return {
"ok": True,
"async": True,
"status": _STATUS_RUNNING,
"job_id": job_id,
"ne_count": int(job["ne_count"]),
"poll_tool": "getNeExecJob",
"poll_path": f"/v1/managed-ne/exec-jobs/{job_id}",
"hint": (
f"exec started in background (job_id={job_id}, ne_count={job['ne_count']}). "
"Tell the user the job_id and end the turn; later call getNeExecJob(job_id=...) "
"or ask the user to continue. Do not sleep/busy-wait in this turn."
),
"example_poll": {"job_id": job_id},
}
def get_ne_exec_job(job_id: str) -> dict[str, Any]:
jid = str(job_id or "").strip()
if not jid:
return {"ok": False, "error_code": "job_id_required", "error": "job_id_required"}
job = _read_job(jid)
if not job:
return {"ok": False, "error_code": "job_not_found", "error": "job_not_found", "job_id": jid}
status = str(job.get("status") or "")
out: dict[str, Any] = {
"ok": True,
"job_id": jid,
"status": status,
"kind": job.get("kind"),
"ne_count": job.get("ne_count"),
"created_at_ms": job.get("created_at_ms"),
"updated_at_ms": job.get("updated_at_ms"),
"duration_ms": job.get("duration_ms"),
"terminal": status in _TERMINAL,
}
if status in _TERMINAL:
out["result"] = job.get("result")
if job.get("error"):
out["error"] = job.get("error")
else:
out["hint"] = "Still running; poll getNeExecJob again later or ask the user to continue."
return out
__all__ = [
"async_min_ne_count",
"count_exec_ne_targets",
"get_ne_exec_job",
"should_run_exec_async",
"start_ne_exec_job",
]

View file

@ -149,12 +149,15 @@ def _send_command_expect_prompt(conn: Any, cmd: str, *, read_timeout: int) -> st
def send_show_command(conn: Any, command: str, *, read_timeout: int = 120) -> str:
"""Send a show/display command via ``send_command`` (wait for device prompt).
"""Send one CLI command via ``send_command`` (wait for device prompt).
Do not use ``send_command_timing`` as the primary path for Cisco config
collection: long idle during ``Building configuration...`` is treated as
end-of-output and truncates the config. Timing is only a fallback when
expect-prompt returns empty after a channel drain (IOSv leftover-prompt bug).
Never re-sends the same command on empty/short output: this path is shared by
show collection and open-policy config/scripts (MikroTik ``add``, etc.). A
leftover-prompt empty read must be fixed by draining *before* send, not by
retrying — retries duplicate side-effecting commands.
Prefer ``send_command`` over ``send_command_timing`` for long config dumps:
idle during ``Building configuration...`` truncates timing reads.
``cmd_verify=False``: Netmiko's default echo check often raises
``Pattern not detected: 'show\\ lldp\\ ...'`` on IOSv / hop / slow echo paths.
@ -163,19 +166,7 @@ def send_show_command(conn: Any, command: str, *, read_timeout: int = 120) -> st
if not cmd:
return ""
# IOSv / hop: find_prompt / paging-off often leave an extra prompt in the
# channel; drain so send_command does not match that leftover immediately.
drain_read_channel(conn)
out = _send_command_expect_prompt(conn, cmd, read_timeout=read_timeout)
if out.strip():
return out
# Leftover prompt matched before command echo — drain again and retry once.
drain_read_channel(conn)
out = _send_command_expect_prompt(conn, cmd, read_timeout=read_timeout)
if out.strip():
return out
drain_read_channel(conn)
try:
return str(conn.send_command_timing(cmd, read_timeout=read_timeout) or "")
except Exception:
return ""
return _send_command_expect_prompt(conn, cmd, read_timeout=read_timeout)

View file

@ -176,6 +176,19 @@ class ManagedNeExecBatchRequest(BaseModel):
concurrency: int | None = Field(default=4, ge=1, le=8)
class ManagedNeExecJobCreate(BaseModel):
"""Start a background single or batch exec (poll GET /v1/managed-ne/exec-jobs/{job_id})."""
ne_id: str | None = None
ume_ne_id: str | None = None
targets: list[ManagedNeExecBatchTarget] | None = Field(default=None, max_length=20)
ne_ids: list[str] | None = Field(default=None, max_length=20)
ume_ne_ids: list[str] | None = Field(default=None, max_length=20)
commands: list[str] | None = Field(default=None, max_length=50)
read_timeout_sec: int | None = Field(default=None, ge=10, le=120)
concurrency: int | None = Field(default=4, ge=1, le=8)
class HopProxyConfig(BaseModel):
"""Shared jump-host (proxy) settings applied to one or many NEs."""

View file

@ -35,7 +35,7 @@ from .ne_service_common import (
)
from .ne_exec_guard import (
EXEC_POLICY_READONLY,
is_linux_device_type,
allows_open_exec_policy,
require_exec_policy_writable,
)
@ -173,8 +173,8 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
str(data["exec_policy"]),
device_type=row.device_type,
)
elif "device_type" in data and not is_linux_device_type(row.device_type):
# Leaving linux clears any previously open policy.
elif "device_type" in data and not allows_open_exec_policy(row.device_type):
# Leaving linux/mikrotik clears any previously open policy.
row.exec_policy = EXEC_POLICY_READONLY
if "password" in data and data["password"]:
_require_crypto()

View file

@ -44,7 +44,7 @@ python -m netx_mcp
`queryNmsAlarms`, `aggregateNmsAlarms`, `runNmsDiagnostics`, `queryNmsNeInventory`, `getNmsNe`, `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms`
**Managed CLI + paths**(问「能否登录」必须走这里,不要只查 inventory):
`listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets`, `findTopologyPaths`
`listManagedNe`, `getManagedNe`, `execManagedNe`, `getNeExecJob`, `listCliTargets`, `findTopologyPaths`
参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。

View file

@ -24,6 +24,57 @@ def exec_max_commands() -> int:
return max(1, min(_EXEC_MAX_COMMANDS_CAP, raw))
def _async_min_nes() -> int:
try:
raw = int(os.getenv("NETX_NE_EXEC_ASYNC_MIN_NES") or 4)
except ValueError:
raw = 4
return max(0, min(50, raw))
def _truthy_async_flag(raw: Any) -> bool | None:
if raw is None:
return None
if isinstance(raw, bool):
return raw
text = str(raw).strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return None
def _count_exec_ne_targets(args: dict[str, Any]) -> int:
n = 0
for key in ("ne_ids", "nms_ne_ids", "ume_ne_ids"):
val = args.get(key)
if isinstance(val, list):
n = max(n, len([x for x in val if str(x or "").strip()]))
targets = args.get("targets")
if isinstance(targets, list):
n = max(n, len([t for t in targets if isinstance(t, dict)]))
if n == 0 and (
str(args.get("ne_id") or "").strip()
or str(args.get("nms_ne_id") or "").strip()
or str(args.get("ume_ne_id") or "").strip()
):
return 1
return int(n)
def _should_run_exec_async(args: dict[str, Any]) -> bool:
flag = _truthy_async_flag(args.get("async"))
if flag is False:
return False
if flag is True:
return True
min_n = _async_min_nes()
if min_n <= 0:
return False
return _count_exec_ne_targets(args) >= min_n
UME_RAW_GROUP_FIELDS = [
"alarm_alarm_key",
"alarm_host_name",
@ -310,6 +361,8 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
or (isinstance(ne_ids_raw, list) and ne_ids_raw)
or (isinstance(nms_ne_ids_raw, list) and nms_ne_ids_raw)
)
want_async = _should_run_exec_async(args)
if multi:
body: dict[str, Any] = {}
if isinstance(targets_raw, list) and targets_raw:
@ -343,6 +396,12 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
conc = args.get("concurrency")
if conc is not None:
body["concurrency"] = int(conc)
if want_async:
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
if not out.get("ok"):
return out
data = out.get("data") if isinstance(out.get("data"), dict) else out
return data if isinstance(data, dict) else {"ok": True, "data": data}
# Wall clock: many NEs × per-cmd timeout; keep below oclaw MCP override.
out = http_post_json("/v1/managed-ne/exec-batch", body, timeout=600.0)
if not out.get("ok"):
@ -377,6 +436,12 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
# Default 60s matches netx API default; slow show commands often exceed 30s.
rts = args.get("read_timeout_sec")
body["read_timeout_sec"] = int(rts) if rts is not None else 60
if want_async:
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
if not out.get("ok"):
return out
data = out.get("data") if isinstance(out.get("data"), dict) else out
return data if isinstance(data, dict) else {"ok": True, "data": data}
out = http_post_json("/v1/managed-ne/exec", body, timeout=300.0)
if not out.get("ok"):
return out
@ -386,6 +451,18 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
return {"ok": True, "data": data}
def _get_ne_exec_job(args: dict[str, Any]) -> dict[str, Any]:
job_id = str(args.get("job_id") or "").strip()
if not job_id:
return {
"ok": False,
"error": "job_id_required",
"error_code": "job_id_required",
"hint": "Pass job_id from execManagedNe async ack.",
}
return http_json("GET", f"/v1/managed-ne/exec-jobs/{quote_ne_id(job_id)}", params=None)
def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
@ -665,6 +742,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"name": "getManagedNe",
"description": (
"Get one **managed** NE by managed ne_id (from listManagedNe / listCliTargets source=managed). "
"Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) "
"— read it before complex execManagedNe. "
"Do NOT pass NMS inventory UUID here — use getNmsNe or execManagedNe(nms_ne_id=...) instead."
),
"inputSchema": {
@ -686,8 +765,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"description": (
f"Run CLI via netx (default read-only: show/display/ping/traceroute; "
f"max {exec_max_commands()} commands per NE, NETX_NE_EXEC_MAX_COMMANDS). "
"Managed NE exec_policy=linux_shell|unrestricted allows single-line shell on that host "
"(check getManagedNe / listManagedNe). "
"Managed NE exec_policy=linux_shell|unrestricted allows shell/script on Linux or "
"MikroTik (routeros/switchos) hosts "
"(pipes/&&/;/quotes/heredoc / RouterOS multiline OK; check getManagedNe / listManagedNe). "
"Single NE: ne_id OR nms_ne_id (+ alias ume_ne_id) + commands. "
"Many NEs (batch-first, server concurrency default 4, max 20): "
"(1) same CLI on all → ne_ids[]/nms_ne_ids[] + shared commands; "
@ -695,8 +775,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"{nms_ne_id|ne_id, commands:[…]}, …] — do NOT fall back to one-NE loops. "
"Do NOT loop one-NE execManagedNe for multi-NE work. "
"Default read_timeout_sec=60; on timeout raise to 90–120 — do not blind-retry. "
"Large batches (≈4+ NEs) may auto-run async in oclaw: returns job_id immediately; "
"poll get_ne_exec_job. Pass async=true to force background, async=false to force sync."
"Long or multi-NE work: async=true (or auto when ≥4 NEs) returns job_id immediately; "
"poll getNeExecJob. Pass async=false to force sync. "
"Read getManagedNe.capability before choosing show_only vs script_on_device."
),
"inputSchema": {
"type": "object",
@ -774,8 +855,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"async": {
"type": "boolean",
"description": (
"oclaw-only: true=background job_id + get_ne_exec_job; "
"false=force sync; omit=auto for large batches (~4+ NEs)."
"true=background job_id + getNeExecJob; false=force sync; "
"omit=auto for large batches (~4+ NEs, NETX_NE_EXEC_ASYNC_MIN_NES)."
),
},
},
@ -783,6 +864,22 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"additionalProperties": False,
},
},
{
"name": "getNeExecJob",
"description": (
"Poll a background execManagedNe job (job_id from async ack). "
"When terminal=true, result holds the exec/exec-batch payload. "
"Do not busy-wait; end the turn and poll later if still running."
),
"inputSchema": {
"type": "object",
"properties": {
"job_id": {"type": "string", "description": "Job id from execManagedNe async response."},
},
"required": ["job_id"],
"additionalProperties": False,
},
},
{
"name": "listCliTargets",
"description": (
@ -872,6 +969,7 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"listManagedNe": _list_managed_ne,
"getManagedNe": _get_managed_ne,
"execManagedNe": _exec_managed_ne,
"getNeExecJob": _get_ne_exec_job,
"listCliTargets": _list_cli_targets,
"findTopologyPaths": _find_topology_paths,
}
@ -890,6 +988,7 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = {
"listManagedNe": "ne:read",
"getManagedNe": "ne:read",
"execManagedNe": "ne:exec",
"getNeExecJob": "ne:exec",
"listCliTargets": "ne:read",
"findTopologyPaths": "ne:read",
}

View file

@ -15,10 +15,11 @@ from netx_mcp.server import _fetch_scopes
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) == 14
assert len(names) == 15
assert "queryNmsAlarms" in names
assert "queryNmsAlarmsRaw" in names
assert "execManagedNe" in names
assert "getNeExecJob" in names
assert "listCliTargets" in names
assert "findTopologyPaths" in names
assert "getBizMonitorContext" not in names

View file

@ -0,0 +1,96 @@
"""Tests for NE capability descriptors and background exec jobs."""
from __future__ import annotations
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from netx_api.ne_capability import build_ne_capability
from netx_api.ne_exec_jobs import (
get_ne_exec_job,
should_run_exec_async,
start_ne_exec_job,
)
class NeCapabilityTests(unittest.TestCase):
def test_linux_shell_recommends_script(self) -> None:
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=True):
cap = build_ne_capability(
device_type="linux",
exec_policy="linux_shell",
vendor="Other",
)
self.assertEqual(cap["device_family"], "linux")
self.assertEqual(cap["exec_policy_effective"], "linux_shell")
self.assertTrue(cap["allows_shell_scripts"])
self.assertTrue(cap["supports_async_job"])
self.assertEqual(cap["recommended_mode"], "script_on_device")
def test_mikrotik_shell_recommends_short_cli(self) -> None:
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=True):
cap = build_ne_capability(
device_type="mikrotik_routeros",
exec_policy="linux_shell",
)
self.assertEqual(cap["device_family"], "mikrotik")
self.assertEqual(cap["recommended_mode"], "short_cli")
self.assertTrue(cap["allows_multiline"])
def test_readonly_network_cli(self) -> None:
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=True):
cap = build_ne_capability(device_type="zte_zxros", exec_policy="readonly")
self.assertEqual(cap["device_family"], "network_cli")
self.assertEqual(cap["recommended_mode"], "show_only")
self.assertFalse(cap["allows_shell_scripts"])
def test_feature_off_forces_readonly_effective(self) -> None:
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=False):
cap = build_ne_capability(device_type="linux", exec_policy="linux_shell")
self.assertEqual(cap["exec_policy_stored"], "linux_shell")
self.assertEqual(cap["exec_policy_effective"], "readonly")
self.assertFalse(cap["allows_shell_scripts"])
class NeExecJobTests(unittest.TestCase):
def test_should_run_async_flags(self) -> None:
self.assertTrue(should_run_exec_async({"async": True, "ne_id": "a"}))
self.assertFalse(should_run_exec_async({"async": False, "ne_ids": ["a", "b", "c", "d", "e"]}))
with patch("netx_api.ne_exec_jobs.async_min_ne_count", return_value=4):
self.assertTrue(
should_run_exec_async({"ne_ids": ["1", "2", "3", "4"]})
)
self.assertFalse(
should_run_exec_async({"ne_ids": ["1", "2"]})
)
def test_job_roundtrip(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
with patch.dict("os.environ", {"NETX_NE_EXEC_JOB_DIR": tmp}):
ack = start_ne_exec_job(
kind="exec",
arguments={"ne_id": "abc", "commands": ["show version"]},
runner=lambda: {"ok": True, "output": "hi"},
)
self.assertTrue(ack.get("ok"))
jid = str(ack.get("job_id") or "")
self.assertTrue(jid)
done = None
for _ in range(50):
polled = get_ne_exec_job(jid)
if polled.get("terminal"):
done = polled
break
time.sleep(0.02)
self.assertIsNotNone(done)
assert done is not None
self.assertEqual(done.get("status"), "succeeded")
self.assertEqual((done.get("result") or {}).get("output"), "hi")
self.assertTrue(Path(tmp, f"{jid}.json").is_file())
if __name__ == "__main__":
unittest.main()

View file

@ -105,6 +105,10 @@ class NeExecValidationTests(unittest.TestCase):
"systemctl status sshd",
"cat /etc/os-release && uname -a",
"df -h; free -m",
"printf 'a\\nb' > /tmp/x",
"python3 -c \"print('hi')\"",
"cat <<'EOF' > /tmp/x\nhello world\nEOF",
"tee /tmp/x <<EOF\nline1\nline2\nEOF",
):
with self.subTest(cmd=cmd):
_validate_command(cmd, policy="linux_shell")
@ -117,13 +121,21 @@ class NeExecValidationTests(unittest.TestCase):
self.assertEqual(effective_exec_policy("linux_shell", device_type="linux"), "readonly")
self.assertEqual(effective_exec_policy("unrestricted", device_type="linux"), "readonly")
def test_effective_policy_forces_readonly_for_non_linux(self) -> None:
def test_effective_policy_forces_readonly_for_ineligible_types(self) -> None:
from netx_api.ne_exec_guard import effective_exec_policy
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=True):
self.assertEqual(effective_exec_policy("linux_shell", device_type="zte_zxros"), "readonly")
self.assertEqual(effective_exec_policy("linux_shell", device_type="linux"), "linux_shell")
self.assertEqual(effective_exec_policy("unrestricted", device_type="linux_ssh"), "unrestricted")
self.assertEqual(
effective_exec_policy("linux_shell", device_type="mikrotik_routeros"),
"linux_shell",
)
self.assertEqual(
effective_exec_policy("unrestricted", device_type="mikrotik_switchos"),
"unrestricted",
)
def test_require_writable_rejects_when_feature_off(self) -> None:
from netx_api.ne_exec_guard import require_exec_policy_writable
@ -134,7 +146,7 @@ class NeExecValidationTests(unittest.TestCase):
require_exec_policy_writable("linux_shell", device_type="linux")
self.assertEqual(ctx.exception.detail, "exec_policy_feature_disabled")
def test_require_writable_rejects_non_linux(self) -> None:
def test_require_writable_rejects_ineligible_types(self) -> None:
from netx_api.ne_exec_guard import require_exec_policy_writable
with patch("netx_api.ne_exec_guard.exec_policy_feature_enabled", return_value=True):
@ -142,15 +154,37 @@ class NeExecValidationTests(unittest.TestCase):
require_exec_policy_writable("linux_shell", device_type="linux"),
"linux_shell",
)
self.assertEqual(
require_exec_policy_writable("linux_shell", device_type="mikrotik_routeros"),
"linux_shell",
)
with self.assertRaises(HTTPException) as ctx:
require_exec_policy_writable("linux_shell", device_type="cisco_ios")
self.assertEqual(ctx.exception.detail, "exec_policy_requires_linux_device_type")
self.assertEqual(ctx.exception.detail, "exec_policy_requires_shell_device_type")
def test_linux_shell_blocks_newline(self) -> None:
def test_linux_shell_allows_multiline_and_chained(self) -> None:
# Newlines are intentional for heredoc / small scripts (agent-friendly).
_validate_command("ls\nrm -rf /", policy="linux_shell")
_validate_command("cat <<'EOF' > /tmp/a\nx\nEOF", policy="unrestricted")
def test_linux_shell_blocks_unicode_line_separator(self) -> None:
with self.assertRaises(HTTPException) as ctx:
_validate_command("ls\nrm -rf /", policy="linux_shell")
_validate_command("ls\u2028rm -rf /", policy="linux_shell")
self.assertEqual(ctx.exception.detail, "command_chars_not_allowed")
def test_linux_shell_blocks_too_many_lines(self) -> None:
body = "\n".join(["echo x"] * 2001)
with self.assertRaises(HTTPException) as ctx:
_validate_command(body, policy="linux_shell")
self.assertEqual(ctx.exception.detail, "command_too_many_lines")
def test_linux_shell_allows_longer_than_readonly_cap(self) -> None:
cmd = "echo " + ("a" * 600)
with self.assertRaises(HTTPException) as ctx:
_validate_command(cmd, policy="readonly")
self.assertEqual(ctx.exception.detail, "command_too_long")
_validate_command(cmd, policy="linux_shell")
def test_readonly_still_blocks_linux_cmds(self) -> None:
with self.assertRaises(HTTPException) as ctx:
_validate_command("ls -la", policy="readonly")
@ -285,6 +319,31 @@ class NeExecRunTests(unittest.TestCase):
self.assertEqual(out["device"]["exec_policy"], "linux_shell")
collect.assert_called_once()
@patch("netx_api.ne_exec.credentials_configured", return_value=True)
@patch("netx_api.ne_exec._collect_on_device", return_value="ros-ok")
@patch("netx_api.ne_exec.resolve_cli_target")
def test_execute_mikrotik_shell_policy_allows_multiline(self, resolve, collect, _configured) -> None:
resolve.return_value = (
_ready_creds(),
{
"source": "managed",
"id": "mt-1",
"exec_policy": "linux_shell",
"name": "rb5009",
"device_type": "mikrotik_routeros",
"ip_address": "10.0.0.10",
},
)
db = MagicMock()
script = ":local x 1\n:put $x\n/ip address print"
with patch("netx_api.config.settings") as mock_settings:
mock_settings.ne_exec_policy_enabled = True
out = execute_managed_ne_commands(db, [script], ne_id="mt-1")
self.assertTrue(out["ok"])
self.assertEqual(out["output"], "ros-ok")
self.assertEqual(out["device"]["exec_policy"], "linux_shell")
collect.assert_called_once()
@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")

View file

@ -1,9 +1,9 @@
"""Tests for Netmiko show helpers (IOSv leftover-prompt drain / retry)."""
"""Tests for Netmiko show helpers (IOSv leftover-prompt drain; no re-send)."""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, call, patch
from unittest.mock import MagicMock, patch
from netx_api.ne_netmiko import drain_read_channel, send_show_command
@ -24,25 +24,24 @@ class DrainReadChannelTests(unittest.TestCase):
class SendShowCommandTests(unittest.TestCase):
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_returns_first_nonempty_send_command(self, drain: MagicMock) -> None:
def test_drains_then_sends_once(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.send_command.return_value = "*12:00:00 UTC"
out = send_show_command(conn, "show clock", read_timeout=30)
self.assertEqual(out, "*12:00:00 UTC")
conn.send_command.assert_called_once()
conn.send_command_timing.assert_not_called()
drain.assert_called()
drain.assert_called_once_with(conn)
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_retries_then_falls_back_to_timing_when_empty(self, drain: MagicMock) -> None:
def test_empty_output_does_not_resend(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.send_command.return_value = ""
conn.send_command_timing.return_value = "Cisco IOS Software"
out = send_show_command(conn, "show version", read_timeout=30)
self.assertEqual(out, "Cisco IOS Software")
self.assertEqual(conn.send_command.call_count, 2)
conn.send_command_timing.assert_called_once_with("show version", read_timeout=30)
self.assertGreaterEqual(drain.call_count, 2)
self.assertEqual(out, "")
conn.send_command.assert_called_once()
conn.send_command_timing.assert_not_called()
drain.assert_called_once_with(conn)
if __name__ == "__main__":

View file

@ -1640,9 +1640,9 @@ const en = {
},
execPolicy: {
readonly: "Read-only (show/display/ping)",
linuxShell: "Linux shell (single line)",
unrestricted: "Lab open (single line)",
hint: "Applies to MCP/API execManagedNe only. Default is read-only; shell / lab-open require a Linux device type.",
linuxShell: "Shell / script (multiline / heredoc OK)",
unrestricted: "Lab open (multiline / heredoc OK)",
hint: "Applies to MCP/API execManagedNe only. Default is read-only; Linux and MikroTik (routeros/switchos) may use shell / lab-open for multiline scripts and heredoc.",
},
source: {
manual: "Manual",

View file

@ -1627,9 +1627,9 @@ const zh = {
},
execPolicy: {
readonly: "只读(show/display/ping)",
linuxShell: "Linux shell(单行)",
unrestricted: "实验室全开(单行)",
hint: "仅影响 MCP/API 的 execManagedNe。默认只读;仅 Linux 设备类型可选 shell / 实验室全开。",
linuxShell: "Shell/脚本(允许多行/heredoc)",
unrestricted: "实验室全开(允许多行/heredoc)",
hint: "仅影响 MCP/API 的 execManagedNe。默认只读;Linux 与 MikroTik(routeros/switchos)可选 shell / 实验室全开,支持多行脚本与 heredoc。",
},
source: {
manual: "手工",

View file

@ -14,7 +14,7 @@ import {
buildManagedNeSaveBody,
emptyManagedNeForm,
formFromManagedNe,
isLinuxDeviceType,
allowsOpenExecPolicy,
type ManagedNeFormState,
} from "./formState";
@ -139,7 +139,7 @@ export function ManagedNeFormDialog({
setForm((prev) => ({
...prev,
device_type,
exec_policy: isLinuxDeviceType(device_type) ? prev.exec_policy : "readonly",
exec_policy: allowsOpenExecPolicy(device_type) ? prev.exec_policy : "readonly",
}));
}}
>
@ -162,7 +162,7 @@ export function ManagedNeFormDialog({
}
>
<option value="readonly">{t("managedNe.execPolicy.readonly")}</option>
{isLinuxDeviceType(form.device_type) ? (
{allowsOpenExecPolicy(form.device_type) ? (
<>
<option value="linux_shell">{t("managedNe.execPolicy.linuxShell")}</option>
<option value="unrestricted">{t("managedNe.execPolicy.unrestricted")}</option>

View file

@ -46,6 +46,22 @@ export function isLinuxDeviceType(deviceType: string | undefined | null): boolea
return low === "linux" || low === "linux_ssh" || low === "linux_telnet" || low.startsWith("linux_");
}
export function isMikrotikDeviceType(deviceType: string | undefined | null): boolean {
const low = String(deviceType || "")
.trim()
.toLowerCase();
return (
low === "mikrotik_routeros" ||
low === "mikrotik_switchos" ||
low.startsWith("mikrotik_")
);
}
/** Linux + MikroTik may use linux_shell / unrestricted (multiline scripts). */
export function allowsOpenExecPolicy(deviceType: string | undefined | null): boolean {
return isLinuxDeviceType(deviceType) || isMikrotikDeviceType(deviceType);
}
export function emptyManagedNeForm(): ManagedNeFormState {
return {
name: "",
@ -173,7 +189,7 @@ export function buildManagedNeSaveBody(
...(form.hop_password ? { hop_password: form.hop_password } : {}),
};
if (opts.execPolicyEnabled) {
body.exec_policy = isLinuxDeviceType(form.device_type) ? form.exec_policy : "readonly";
body.exec_policy = allowsOpenExecPolicy(form.device_type) ? form.exec_policy : "readonly";
}
if (form.hop_enabled) {
if (!form.hop_host.trim()) throw new Error(opts.hopHostRequired);