mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
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>
172 lines
6.9 KiB
Python
172 lines
6.9 KiB
Python
"""NE CLI command allow/deny gates (execManagedNe / ops tools).
|
|
|
|
Policies (per managed NE ``exec_policy``):
|
|
|
|
- ``readonly`` (default): network CLI only — show/display/ping/traceroute.
|
|
- ``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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from fastapi import HTTPException
|
|
|
|
EXEC_POLICY_READONLY = "readonly"
|
|
EXEC_POLICY_LINUX_SHELL = "linux_shell"
|
|
EXEC_POLICY_UNRESTRICTED = "unrestricted"
|
|
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)("
|
|
r"configure\s+terminal|conf\s+t\b|"
|
|
r"\bwrite\s+(memory|erase)|\bcopy\s+run|\bcopy\s+startup|"
|
|
r"\breload\b|\breboot\b|\berase\b|\bformat\b|\bdelete\b|"
|
|
r"\bcommit\b|\brollback\b|startup-config|"
|
|
r"\bsystem-view\b|\bip\s+address\b|\bvlan\s+\d|"
|
|
# Extra vendor / destructive surface (avoid words that appear in show output filters)
|
|
r"\bclear\s+configuration\b|\breset\s+saved-configuration\b|"
|
|
r"\bundo\s+|\bsave\s*$|\bsave\s+\S|"
|
|
r"\bfile\s+delete\b|\bftp\s+put\b|\btftp\s+put\b|"
|
|
r"\bdebug\s+all\b|\bundebug\s+all\b|"
|
|
r"\brequest\s+system\s+(reboot|halt|power-off|zeroize)\b|"
|
|
r"\bset\s+system\s+reboot\b"
|
|
r")"
|
|
)
|
|
|
|
# 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")
|
|
|
|
# Pipe segments allowed after show/display (output filtering only).
|
|
_ALLOWED_PIPE_SEGMENT_RE = re.compile(
|
|
r"(?i)^(include|exclude|begin|section|count|match|grep|one-line|no-more)(\s|$)"
|
|
)
|
|
_BLOCKED_PIPE_SEGMENT_RE = re.compile(r"(?i)\b(redirect|append|tee|send)\b")
|
|
|
|
|
|
def normalize_exec_policy(raw: str | None) -> str:
|
|
p = str(raw or "").strip().lower()
|
|
return p if p in EXEC_POLICIES else EXEC_POLICY_READONLY
|
|
|
|
|
|
def is_linux_device_type(device_type: str | None) -> bool:
|
|
low = str(device_type or "").strip().lower()
|
|
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
|
|
|
|
return bool(getattr(settings, "ne_exec_policy_enabled", False))
|
|
|
|
|
|
def effective_exec_policy(raw: str | None, *, device_type: str | None = None) -> str:
|
|
"""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 allows_open_exec_policy(device_type):
|
|
return EXEC_POLICY_READONLY
|
|
return pol
|
|
|
|
|
|
def require_exec_policy_writable(
|
|
raw: str | None,
|
|
*,
|
|
device_type: str | None = None,
|
|
) -> str:
|
|
"""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 allows_open_exec_policy(device_type):
|
|
raise HTTPException(status_code=400, detail="exec_policy_requires_shell_device_type")
|
|
return pol
|
|
|
|
|
|
def _validate_pipe_segments(cmd: str) -> None:
|
|
if "|" not in cmd:
|
|
return
|
|
parts = [p.strip() for p in cmd.split("|")]
|
|
if len(parts) < 2 or not parts[0] or any(not p for p in parts[1:]):
|
|
raise HTTPException(status_code=400, detail="command_pipe_not_allowed")
|
|
for segment in parts[1:]:
|
|
if _BLOCKED_PIPE_SEGMENT_RE.search(segment):
|
|
raise HTTPException(status_code=400, detail="command_pipe_not_allowed")
|
|
if not _ALLOWED_PIPE_SEGMENT_RE.match(segment):
|
|
raise HTTPException(status_code=400, detail="command_pipe_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:
|
|
if any(ch in cmd for ch in (";", "\n", "\r", "`")):
|
|
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
|
|
if any(sep in cmd for sep in _FORBIDDEN_LINE_SEPARATORS):
|
|
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
|
|
if _BLOCKED_RE.search(cmd):
|
|
raise HTTPException(status_code=400, detail="command_blocked")
|
|
if not _ALLOWED_PREFIX_RE.match(cmd):
|
|
raise HTTPException(status_code=400, detail="command_not_allowed_prefix")
|
|
_validate_pipe_segments(cmd)
|
|
|
|
|
|
def validate_ne_exec_command(command: str, *, policy: str = EXEC_POLICY_READONLY) -> None:
|
|
"""Raise HTTPException if command is empty, smuggled, blocked, or not allowlisted."""
|
|
cmd = str(command or "").strip()
|
|
if not cmd:
|
|
raise HTTPException(status_code=400, detail="empty_command")
|
|
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):
|
|
# Shell metacharacters (|;&&`$'"<<) and newlines (heredoc) allowed.
|
|
_validate_linux_shell_command(cmd)
|
|
return
|
|
_validate_readonly_command(cmd)
|
|
|
|
|
|
# Back-compat alias used by tests / callers.
|
|
_validate_command = validate_ne_exec_command
|