mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Add opt-in per-NE CLI exec_policy for MCP/API exec.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bfac0b53c8
commit
b9be545a75
20 changed files with 463 additions and 24 deletions
|
|
@ -5,6 +5,8 @@ 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+.
|
# Managed NE CLI: max commands per exec request (default 5, hard cap 50). Lab can set 10+.
|
||||||
NETX_NE_EXEC_MAX_COMMANDS=5
|
NETX_NE_EXEC_MAX_COMMANDS=5
|
||||||
|
# Opt-in: show/allow per-NE exec_policy (linux_shell|unrestricted). Default false.
|
||||||
|
NETX_NE_EXEC_POLICY_ENABLED=false
|
||||||
NETX_HOST=127.0.0.1
|
NETX_HOST=127.0.0.1
|
||||||
NETX_PORT=8890
|
NETX_PORT=8890
|
||||||
NETX_VENDOR=ZTE
|
NETX_VENDOR=ZTE
|
||||||
|
|
|
||||||
35
alembic/versions/20260921_exec_policy.py
Normal file
35
alembic/versions/20260921_exec_policy.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Add managed_ne.exec_policy for per-NE CLI exec gates.
|
||||||
|
|
||||||
|
Revision ID: 20260921_exec_policy
|
||||||
|
Revises: 20260812_ne_collect_trigger
|
||||||
|
Create Date: 2026-09-21
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260921_exec_policy"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "20260812_ne_collect_trigger"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
from netx_api.schema_patches import apply_hop_schema_safety_net
|
||||||
|
|
||||||
|
apply_hop_schema_safety_net(op.get_bind())
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
dialect = str(getattr(bind.dialect, "name", "") or "").lower()
|
||||||
|
if dialect.startswith("postgres"):
|
||||||
|
op.execute("ALTER TABLE managed_ne DROP COLUMN IF EXISTS exec_policy")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
op.drop_column("managed_ne", "exec_policy")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
@ -43,6 +43,10 @@ def run_api_startup() -> None:
|
||||||
"""Full API boot sequence previously inlined in ``main.on_startup``."""
|
"""Full API boot sequence previously inlined in ``main.on_startup``."""
|
||||||
assert_secure_defaults_or_exit()
|
assert_secure_defaults_or_exit()
|
||||||
_configure_ume_diag_logging()
|
_configure_ume_diag_logging()
|
||||||
|
_log.info(
|
||||||
|
"startup: ne_exec_policy_enabled=%s",
|
||||||
|
bool(getattr(settings, "ne_exec_policy_enabled", False)),
|
||||||
|
)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
alembic_ok = True
|
alembic_ok = True
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||||
from .device_types import SUPPORTED_DEVICE_TYPES
|
from .device_types import SUPPORTED_DEVICE_TYPES
|
||||||
from .models import CliConnectProfile, ManagedNE, UmeCliOverride, UmeInventoryNE
|
from .models import CliConnectProfile, ManagedNE, UmeCliOverride, UmeInventoryNE
|
||||||
from .ne_crypto import decrypt_secret
|
from .ne_crypto import decrypt_secret
|
||||||
|
from .ne_exec_guard import EXEC_POLICY_READONLY, normalize_exec_policy
|
||||||
from .ne_service import get_device_credentials, row_to_out
|
from .ne_service import get_device_credentials, row_to_out
|
||||||
|
|
||||||
_BUILTIN_NE_TYPE_RULES: list[tuple[re.Pattern[str], str, str]] = [
|
_BUILTIN_NE_TYPE_RULES: list[tuple[re.Pattern[str], str, str]] = [
|
||||||
|
|
@ -141,6 +142,7 @@ def resolve_cli_target(
|
||||||
"port": meta["port"],
|
"port": meta["port"],
|
||||||
"protocol": meta["protocol"],
|
"protocol": meta["protocol"],
|
||||||
"connect_status": meta["connect_status"],
|
"connect_status": meta["connect_status"],
|
||||||
|
"exec_policy": normalize_exec_policy(meta.get("exec_policy")),
|
||||||
"hop_enabled": meta["hop_enabled"],
|
"hop_enabled": meta["hop_enabled"],
|
||||||
"hop_vendor": meta["hop_vendor"],
|
"hop_vendor": meta["hop_vendor"],
|
||||||
}
|
}
|
||||||
|
|
@ -198,6 +200,7 @@ def resolve_cli_target(
|
||||||
"port": int(profile.port or 22),
|
"port": int(profile.port or 22),
|
||||||
"protocol": str(profile.protocol or "ssh"),
|
"protocol": str(profile.protocol or "ssh"),
|
||||||
"connect_status": connect_status,
|
"connect_status": connect_status,
|
||||||
|
"exec_policy": EXEC_POLICY_READONLY,
|
||||||
"hop_enabled": bool(profile.hop_enabled),
|
"hop_enabled": bool(profile.hop_enabled),
|
||||||
"hop_vendor": str(profile.hop_vendor or ""),
|
"hop_vendor": str(profile.hop_vendor or ""),
|
||||||
"cli_profile_id": str(profile.id),
|
"cli_profile_id": str(profile.id),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
# Always load repo-root `.env` (cwd-independent). Optional cwd `.env` overrides last.
|
||||||
|
_NETX_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
_ENV_FILES = (str(_NETX_ROOT / ".env"), ".env")
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_prefix="NETX_", extra="ignore")
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=_ENV_FILES,
|
||||||
|
env_prefix="NETX_",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
database_url: str = "postgresql+psycopg://netx:netx@127.0.0.1:5432/netx"
|
database_url: str = "postgresql+psycopg://netx:netx@127.0.0.1:5432/netx"
|
||||||
host: str = "127.0.0.1"
|
host: str = "127.0.0.1"
|
||||||
|
|
@ -114,6 +124,8 @@ class Settings(BaseSettings):
|
||||||
biz_state_heavy_workers: int = 4
|
biz_state_heavy_workers: int = 4
|
||||||
# Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec).
|
# Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec).
|
||||||
ne_exec_max_commands: int = 5
|
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
|
||||||
# WebCRT interactive terminal sessions (multi-operator concurrent terminals).
|
# WebCRT interactive terminal sessions (multi-operator concurrent terminals).
|
||||||
webcrt_max_sessions: int = 40
|
webcrt_max_sessions: int = 40
|
||||||
# Per-user cap (0 = unlimited beyond global max).
|
# Per-user cap (0 = unlimited beyond global max).
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,14 @@ def api_list_managed_ne(
|
||||||
@router.get("/meta/device-types")
|
@router.get("/meta/device-types")
|
||||||
def api_device_types():
|
def api_device_types():
|
||||||
# Include generic/linux so LLDP/WebCRT placeholders can be edited without a bogus select value.
|
# Include generic/linux so LLDP/WebCRT placeholders can be edited without a bogus select value.
|
||||||
|
from .config import settings
|
||||||
from .device_types import WEBCRT_DEVICE_TYPES
|
from .device_types import WEBCRT_DEVICE_TYPES
|
||||||
|
|
||||||
return {"device_types": list(WEBCRT_DEVICE_TYPES), "vendors": list(SUPPORTED_VENDORS)}
|
return {
|
||||||
|
"device_types": list(WEBCRT_DEVICE_TYPES),
|
||||||
|
"vendors": list(SUPPORTED_VENDORS),
|
||||||
|
"exec_policy_enabled": bool(getattr(settings, "ne_exec_policy_enabled", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/meta/credentials-configured")
|
@router.get("/meta/credentials-configured")
|
||||||
|
|
@ -150,7 +155,7 @@ def api_exec_managed_ne(
|
||||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Login to a managed NE or UME inventory NE and run read-only CLI (show/display/ping/traceroute)."""
|
"""Login to a managed NE or UME inventory NE and run CLI (policy from managed NE exec_policy)."""
|
||||||
uid, uname = _actor(ctx)
|
uid, uname = _actor(ctx)
|
||||||
out = execute_managed_ne_commands(
|
out = execute_managed_ne_commands(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ class ManagedNE(Base):
|
||||||
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||||
vendor: Mapped[str] = mapped_column(String(64), default="Other", index=True)
|
vendor: Mapped[str] = mapped_column(String(64), default="Other", index=True)
|
||||||
device_type: Mapped[str] = mapped_column(String(128), default="")
|
device_type: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
# MCP/API execManagedNe command gate: readonly | linux_shell | unrestricted
|
||||||
|
exec_policy: Mapped[str] = mapped_column(String(32), default="readonly")
|
||||||
# Not unique: WebCRT sessions may share a host IP with distinct session names.
|
# Not unique: WebCRT sessions may share a host IP with distinct session names.
|
||||||
# Inventory create/update still enforces uniqueness in ne_service.
|
# Inventory create/update still enforces uniqueness in ne_service.
|
||||||
ip_address: Mapped[str] = mapped_column(String(128), index=True)
|
ip_address: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ from .config import settings
|
||||||
from .db import SessionLocal
|
from .db import SessionLocal
|
||||||
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
|
||||||
from .ne_exec_guard import _validate_command, validate_ne_exec_command
|
from .ne_exec_guard import (
|
||||||
|
_validate_command,
|
||||||
|
effective_exec_policy,
|
||||||
|
normalize_exec_policy,
|
||||||
|
validate_ne_exec_command,
|
||||||
|
)
|
||||||
|
|
||||||
_EXEC_MAX_COMMANDS_CAP = 50
|
_EXEC_MAX_COMMANDS_CAP = 50
|
||||||
_EXEC_MAX_OUTPUT = 32_000
|
_EXEC_MAX_OUTPUT = 32_000
|
||||||
|
|
@ -28,6 +33,8 @@ __all__ = [
|
||||||
"_validate_command",
|
"_validate_command",
|
||||||
"execute_managed_ne_commands",
|
"execute_managed_ne_commands",
|
||||||
"execute_managed_ne_commands_batch",
|
"execute_managed_ne_commands_batch",
|
||||||
|
"effective_exec_policy",
|
||||||
|
"normalize_exec_policy",
|
||||||
"validate_ne_exec_command",
|
"validate_ne_exec_command",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -62,10 +69,16 @@ def execute_managed_ne_commands(
|
||||||
max_cmds = _exec_max_commands()
|
max_cmds = _exec_max_commands()
|
||||||
if len(cmds) > max_cmds:
|
if len(cmds) > max_cmds:
|
||||||
raise HTTPException(status_code=400, detail=f"too_many_commands (max {max_cmds})")
|
raise HTTPException(status_code=400, detail=f"too_many_commands (max {max_cmds})")
|
||||||
for c in cmds:
|
|
||||||
validate_ne_exec_command(c)
|
|
||||||
|
|
||||||
creds, device = resolve_cli_target(db, managed_ne_id=mid or None, ume_ne_id=uid or None)
|
creds, device = resolve_cli_target(db, managed_ne_id=mid or None, ume_ne_id=uid or None)
|
||||||
|
exec_policy = effective_exec_policy(
|
||||||
|
(device or {}).get("exec_policy") or (creds or {}).get("exec_policy"),
|
||||||
|
device_type=(device or {}).get("device_type") or (creds or {}).get("device_type"),
|
||||||
|
)
|
||||||
|
if isinstance(device, dict):
|
||||||
|
device["exec_policy"] = exec_policy
|
||||||
|
for c in cmds:
|
||||||
|
validate_ne_exec_command(c, policy=exec_policy)
|
||||||
skip = cli_creds_skip_reason(creds, interactive=False)
|
skip = cli_creds_skip_reason(creds, interactive=False)
|
||||||
if skip:
|
if skip:
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
"""NE CLI command allow/deny gates (read-only exec for ops tools)."""
|
"""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``: single-line shell; 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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -6,6 +13,13 @@ import re
|
||||||
|
|
||||||
from fastapi import HTTPException
|
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}
|
||||||
|
)
|
||||||
|
|
||||||
# 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)("
|
||||||
|
|
@ -39,6 +53,49 @@ _ALLOWED_PIPE_SEGMENT_RE = re.compile(
|
||||||
_BLOCKED_PIPE_SEGMENT_RE = re.compile(r"(?i)\b(redirect|append|tee|send)\b")
|
_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 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 non-linux)."""
|
||||||
|
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):
|
||||||
|
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 non-linux."""
|
||||||
|
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")
|
||||||
|
return pol
|
||||||
|
|
||||||
|
|
||||||
def _validate_pipe_segments(cmd: str) -> None:
|
def _validate_pipe_segments(cmd: str) -> None:
|
||||||
if "|" not in cmd:
|
if "|" not in cmd:
|
||||||
return
|
return
|
||||||
|
|
@ -52,13 +109,14 @@ def _validate_pipe_segments(cmd: str) -> None:
|
||||||
raise HTTPException(status_code=400, detail="command_pipe_not_allowed")
|
raise HTTPException(status_code=400, detail="command_pipe_not_allowed")
|
||||||
|
|
||||||
|
|
||||||
def validate_ne_exec_command(command: str) -> None:
|
def _validate_single_line(cmd: str) -> None:
|
||||||
"""Raise HTTPException if command is empty, smuggled, blocked, or not allowlisted."""
|
if any(ch in cmd for ch in ("\n", "\r")):
|
||||||
cmd = str(command or "").strip()
|
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
|
||||||
if not cmd:
|
if any(sep in cmd for sep in _FORBIDDEN_LINE_SEPARATORS):
|
||||||
raise HTTPException(status_code=400, detail="empty_command")
|
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
|
||||||
if len(cmd) > 500:
|
|
||||||
raise HTTPException(status_code=400, detail="command_too_long")
|
|
||||||
|
def _validate_readonly_command(cmd: str) -> None:
|
||||||
if any(ch in cmd for ch in (";", "\n", "\r", "`")):
|
if any(ch in cmd for ch in (";", "\n", "\r", "`")):
|
||||||
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
|
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
|
||||||
if any(sep in cmd for sep in _FORBIDDEN_LINE_SEPARATORS):
|
if any(sep in cmd for sep in _FORBIDDEN_LINE_SEPARATORS):
|
||||||
|
|
@ -70,5 +128,20 @@ def validate_ne_exec_command(command: str) -> None:
|
||||||
_validate_pipe_segments(cmd)
|
_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")
|
||||||
|
if len(cmd) > 500:
|
||||||
|
raise HTTPException(status_code=400, detail="command_too_long")
|
||||||
|
pol = normalize_exec_policy(policy)
|
||||||
|
if pol in (EXEC_POLICY_LINUX_SHELL, EXEC_POLICY_UNRESTRICTED):
|
||||||
|
# One command string per slot; shell metacharacters (|;&&`$) allowed.
|
||||||
|
_validate_single_line(cmd)
|
||||||
|
return
|
||||||
|
_validate_readonly_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
# Back-compat alias used by tests / callers.
|
# Back-compat alias used by tests / callers.
|
||||||
_validate_command = validate_ne_exec_command
|
_validate_command = validate_ne_exec_command
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ from typing import Literal
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from .device_types import SUPPORTED_VENDORS
|
from .device_types import SUPPORTED_VENDORS
|
||||||
|
from .ne_exec_guard import EXEC_POLICIES, EXEC_POLICY_READONLY
|
||||||
|
|
||||||
ConnectStatus = Literal["unknown", "testing", "pass", "fail"]
|
ConnectStatus = Literal["unknown", "testing", "pass", "fail"]
|
||||||
|
ExecPolicy = Literal["readonly", "linux_shell", "unrestricted"]
|
||||||
|
|
||||||
|
|
||||||
class ManagedNeCreate(BaseModel):
|
class ManagedNeCreate(BaseModel):
|
||||||
|
|
@ -21,6 +23,7 @@ class ManagedNeCreate(BaseModel):
|
||||||
password: str = ""
|
password: str = ""
|
||||||
tags: str = ""
|
tags: str = ""
|
||||||
remark: str = ""
|
remark: str = ""
|
||||||
|
exec_policy: ExecPolicy = "readonly"
|
||||||
hop_enabled: bool = False
|
hop_enabled: bool = False
|
||||||
hop_vendor: str = "zte"
|
hop_vendor: str = "zte"
|
||||||
hop_host: str = ""
|
hop_host: str = ""
|
||||||
|
|
@ -44,6 +47,16 @@ class ManagedNeCreate(BaseModel):
|
||||||
return item
|
return item
|
||||||
return "Other"
|
return "Other"
|
||||||
|
|
||||||
|
@field_validator("exec_policy", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_exec_policy_create(cls, v: object) -> str:
|
||||||
|
if v is None or str(v).strip() == "":
|
||||||
|
return EXEC_POLICY_READONLY
|
||||||
|
raw = str(v).strip().lower()
|
||||||
|
if raw not in EXEC_POLICIES:
|
||||||
|
raise ValueError("unsupported_exec_policy")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
class ManagedNeUpdate(BaseModel):
|
class ManagedNeUpdate(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
|
@ -56,6 +69,7 @@ class ManagedNeUpdate(BaseModel):
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
tags: str | None = None
|
tags: str | None = None
|
||||||
remark: str | None = None
|
remark: str | None = None
|
||||||
|
exec_policy: ExecPolicy | None = None
|
||||||
hop_enabled: bool | None = None
|
hop_enabled: bool | None = None
|
||||||
hop_vendor: str | None = None
|
hop_vendor: str | None = None
|
||||||
hop_host: str | None = None
|
hop_host: str | None = None
|
||||||
|
|
@ -81,6 +95,18 @@ class ManagedNeUpdate(BaseModel):
|
||||||
return item
|
return item
|
||||||
return "Other"
|
return "Other"
|
||||||
|
|
||||||
|
@field_validator("exec_policy", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_exec_policy_update(cls, v: object) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
raw = str(v).strip().lower()
|
||||||
|
if not raw:
|
||||||
|
return EXEC_POLICY_READONLY
|
||||||
|
if raw not in EXEC_POLICIES:
|
||||||
|
raise ValueError("unsupported_exec_policy")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
class ManagedNeOut(BaseModel):
|
class ManagedNeOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -102,6 +128,7 @@ class ManagedNeOut(BaseModel):
|
||||||
# Provenance: "" | ume_sync | webcrt | lldp | …
|
# Provenance: "" | ume_sync | webcrt | lldp | …
|
||||||
source: str = ""
|
source: str = ""
|
||||||
source_ref: str = ""
|
source_ref: str = ""
|
||||||
|
exec_policy: ExecPolicy = "readonly"
|
||||||
hop_enabled: bool = False
|
hop_enabled: bool = False
|
||||||
hop_vendor: str = "zte"
|
hop_vendor: str = "zte"
|
||||||
hop_host: str = ""
|
hop_host: str = ""
|
||||||
|
|
@ -121,7 +148,7 @@ class ConnectTestRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ManagedNeExecRequest(BaseModel):
|
class ManagedNeExecRequest(BaseModel):
|
||||||
"""Run read-only show/display CLI on a managed NE or UME inventory NE (oclaw ops integration)."""
|
"""Run CLI on a managed NE or UME inventory NE (gates follow managed NE exec_policy)."""
|
||||||
|
|
||||||
ne_id: str | None = None
|
ne_id: str | None = None
|
||||||
ume_ne_id: str | None = None
|
ume_ne_id: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_se
|
||||||
from .ne_schemas import ManagedNeCreate, ManagedNeOut, ManagedNeUpdate
|
from .ne_schemas import ManagedNeCreate, ManagedNeOut, ManagedNeUpdate
|
||||||
from .ne_hop_templates import expand_bastion_hop_fields, normalize_hop_host
|
from .ne_hop_templates import expand_bastion_hop_fields, normalize_hop_host
|
||||||
from .ne_session_factory import default_bastion_username_template, default_hop_command_template
|
from .ne_session_factory import default_bastion_username_template, default_hop_command_template
|
||||||
|
from .ne_exec_guard import normalize_exec_policy
|
||||||
from .timeutil import utcnow_naive
|
from .timeutil import utcnow_naive
|
||||||
|
|
||||||
IMPORT_COLUMNS = (
|
IMPORT_COLUMNS = (
|
||||||
|
|
@ -241,6 +242,7 @@ def row_to_out(row: ManagedNE) -> ManagedNeOut:
|
||||||
remark=str(row.remark or ""),
|
remark=str(row.remark or ""),
|
||||||
source=str(row.source or ""),
|
source=str(row.source or ""),
|
||||||
source_ref=str(row.source_ref or ""),
|
source_ref=str(row.source_ref or ""),
|
||||||
|
exec_policy=normalize_exec_policy(getattr(row, "exec_policy", None)), # type: ignore[arg-type]
|
||||||
hop_enabled=bool(row.hop_enabled),
|
hop_enabled=bool(row.hop_enabled),
|
||||||
hop_vendor=str(row.hop_vendor or "zte"),
|
hop_vendor=str(row.hop_vendor or "zte"),
|
||||||
hop_host=str(row.hop_host or ""),
|
hop_host=str(row.hop_host or ""),
|
||||||
|
|
@ -273,6 +275,7 @@ def get_device_credentials(row: ManagedNE) -> dict[str, Any]:
|
||||||
"password": decrypt_secret(row.password_enc),
|
"password": decrypt_secret(row.password_enc),
|
||||||
"enable_secret": decrypt_secret(row.enable_secret_enc),
|
"enable_secret": decrypt_secret(row.enable_secret_enc),
|
||||||
"name": str(row.name or ""),
|
"name": str(row.name or ""),
|
||||||
|
"exec_policy": normalize_exec_policy(getattr(row, "exec_policy", None)),
|
||||||
"hop_enabled": hop_enabled,
|
"hop_enabled": hop_enabled,
|
||||||
"hop_vendor": str(row.hop_vendor or "zte"),
|
"hop_vendor": str(row.hop_vendor or "zte"),
|
||||||
"hop_host": str(row.hop_host or ""),
|
"hop_host": str(row.hop_host or ""),
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ from .ne_service_common import (
|
||||||
_validate_hop_on_create,
|
_validate_hop_on_create,
|
||||||
row_to_out,
|
row_to_out,
|
||||||
)
|
)
|
||||||
|
from .ne_exec_guard import (
|
||||||
|
EXEC_POLICY_READONLY,
|
||||||
|
is_linux_device_type,
|
||||||
|
require_exec_policy_writable,
|
||||||
|
)
|
||||||
|
|
||||||
# Inventory create stays strict; updates must also accept WebCRT/LLDP placeholder types
|
# Inventory create stays strict; updates must also accept WebCRT/LLDP placeholder types
|
||||||
# (generic/linux) so operators can open the form and promote them to zte_zxros etc.
|
# (generic/linux) so operators can open the form and promote them to zte_zxros etc.
|
||||||
|
|
@ -116,6 +121,10 @@ def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut:
|
||||||
remark=str(body.remark or "").strip(),
|
remark=str(body.remark or "").strip(),
|
||||||
source="",
|
source="",
|
||||||
source_ref="",
|
source_ref="",
|
||||||
|
exec_policy=require_exec_policy_writable(
|
||||||
|
getattr(body, "exec_policy", None),
|
||||||
|
device_type=body.device_type,
|
||||||
|
),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
|
|
@ -159,6 +168,14 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
|
||||||
row.tags = str(data["tags"]).strip()
|
row.tags = str(data["tags"]).strip()
|
||||||
if "remark" in data and data["remark"] is not None:
|
if "remark" in data and data["remark"] is not None:
|
||||||
row.remark = str(data["remark"]).strip()
|
row.remark = str(data["remark"]).strip()
|
||||||
|
if "exec_policy" in data and data["exec_policy"] is not None:
|
||||||
|
row.exec_policy = require_exec_policy_writable(
|
||||||
|
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.
|
||||||
|
row.exec_policy = EXEC_POLICY_READONLY
|
||||||
if "password" in data and data["password"]:
|
if "password" in data and data["password"]:
|
||||||
_require_crypto()
|
_require_crypto()
|
||||||
row.password_enc = encrypt_secret(str(data["password"]))
|
row.password_enc = encrypt_secret(str(data["password"]))
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ def apply_collection_schema_safety_net(conn: Connection) -> None:
|
||||||
|
|
||||||
|
|
||||||
def apply_hop_schema_safety_net(conn: Connection) -> None:
|
def apply_hop_schema_safety_net(conn: Connection) -> None:
|
||||||
"""Always-on hop columns (Alembic head stamp skips legacy domain patches)."""
|
"""Always-on hop / exec_policy columns (Alembic head stamp skips legacy domain patches)."""
|
||||||
_run_sql(
|
_run_sql(
|
||||||
conn,
|
conn,
|
||||||
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_enter_system_view BOOLEAN DEFAULT FALSE",
|
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_enter_system_view BOOLEAN DEFAULT FALSE",
|
||||||
|
|
@ -209,6 +209,10 @@ def apply_hop_schema_safety_net(conn: Connection) -> None:
|
||||||
conn,
|
conn,
|
||||||
"ALTER TABLE cli_connect_profile ADD COLUMN IF NOT EXISTS hop_enter_system_view BOOLEAN DEFAULT FALSE",
|
"ALTER TABLE cli_connect_profile ADD COLUMN IF NOT EXISTS hop_enter_system_view BOOLEAN DEFAULT FALSE",
|
||||||
)
|
)
|
||||||
|
_run_sql(
|
||||||
|
conn,
|
||||||
|
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS exec_policy VARCHAR(32) DEFAULT 'readonly'",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def apply_key_alert_schema_patches(
|
def apply_key_alert_schema_patches(
|
||||||
|
|
@ -539,14 +543,48 @@ def apply_all_legacy_startup_ddl(engine: Engine) -> None:
|
||||||
|
|
||||||
|
|
||||||
def run_alembic_upgrade_to_head() -> None:
|
def run_alembic_upgrade_to_head() -> None:
|
||||||
"""Programmatic ``alembic upgrade head`` (optional on API start)."""
|
"""Programmatic ``alembic upgrade head`` (optional on API start).
|
||||||
|
|
||||||
|
Skip the full Alembic command when already at head — avoids noisy
|
||||||
|
``Context impl`` logs and lock waits when nothing to apply.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
cfg = Config(str(root / "alembic.ini"))
|
cfg = Config(str(root / "alembic.ini"))
|
||||||
# env.py reads settings.database_url; keep ini placeholder overwritten there.
|
cfg.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
|
script = ScriptDirectory.from_config(cfg)
|
||||||
|
head = script.get_current_head()
|
||||||
|
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||||
|
current: str | None = None
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
try:
|
||||||
|
row = conn.execute(text("SELECT version_num FROM alembic_version")).fetchone()
|
||||||
|
current = str(row[0]) if row and row[0] is not None else None
|
||||||
|
except Exception:
|
||||||
|
current = None
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
if head and current == head:
|
||||||
|
_log.info(
|
||||||
|
"alembic already at head (%s), skip upgrade (%.2fs)",
|
||||||
|
head,
|
||||||
|
time.monotonic() - t0,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_log.info("alembic upgrading %s -> %s …", current, head)
|
||||||
command.upgrade(cfg, "head")
|
command.upgrade(cfg, "head")
|
||||||
_log.info("alembic upgrade head completed")
|
_log.info("alembic upgrade head completed (%.2fs)", time.monotonic() - t0)
|
||||||
|
|
|
||||||
|
|
@ -684,8 +684,10 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"name": "execManagedNe",
|
"name": "execManagedNe",
|
||||||
"description": (
|
"description": (
|
||||||
f"Run read-only CLI via netx (show/display/ping/traceroute; "
|
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). "
|
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). "
|
||||||
"Single NE: ne_id OR nms_ne_id (+ alias ume_ne_id) + commands. "
|
"Single NE: ne_id OR nms_ne_id (+ alias ume_ne_id) + commands. "
|
||||||
"Many NEs (batch-first, server concurrency default 4, max 20): "
|
"Many NEs (batch-first, server concurrency default 4, max 20): "
|
||||||
"(1) same CLI on all → ne_ids[]/nms_ne_ids[] + shared commands; "
|
"(1) same CLI on all → ne_ids[]/nms_ne_ids[] + shared commands; "
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ param(
|
||||||
[int]$Port = 8890,
|
[int]$Port = 8890,
|
||||||
[int]$WebPort = 5173,
|
[int]$WebPort = 5173,
|
||||||
[switch]$SkipInstall = $false,
|
[switch]$SkipInstall = $false,
|
||||||
|
[Alias("Bg")]
|
||||||
[switch]$Background = $false,
|
[switch]$Background = $false,
|
||||||
|
# Common typo for -Background (otherwise PowerShell ignores intent / or fails).
|
||||||
|
[Parameter(DontShow)]
|
||||||
|
[switch]$Backgtound = $false,
|
||||||
[switch]$WithWeb = $false,
|
[switch]$WithWeb = $false,
|
||||||
# Keep collectors inside the API process (legacy). Default: split API + worker.
|
# Keep collectors inside the API process (legacy). Default: split API + worker.
|
||||||
[switch]$InlineSchedulers = $false
|
[switch]$InlineSchedulers = $false
|
||||||
|
|
@ -11,6 +15,11 @@ param(
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if ($Backgtound -and -not $Background) {
|
||||||
|
Write-Host "[WARN] -Backgtound is a typo; treating as -Background." -ForegroundColor Yellow
|
||||||
|
$Background = $true
|
||||||
|
}
|
||||||
|
|
||||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
Set-Location $projectRoot
|
Set-Location $projectRoot
|
||||||
|
|
||||||
|
|
@ -234,10 +243,28 @@ if ($Background) {
|
||||||
Write-Host "Err = $webErrFile"
|
Write-Host "Err = $webErrFile"
|
||||||
}
|
}
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "==> Background services started; this script exits (API/worker/web keep running)." -ForegroundColor Cyan
|
Write-Host "==> Background services started; this script will exit now." -ForegroundColor Cyan
|
||||||
|
Write-Host " API/worker/web keep running in the background — you can close this terminal." -ForegroundColor Cyan
|
||||||
|
Write-Host " API log: $logFile" -ForegroundColor DarkGray
|
||||||
|
Write-Host " Err log: $errFile" -ForegroundColor DarkGray
|
||||||
|
if (-not $InlineSchedulers) {
|
||||||
|
Write-Host " Worker: $workerLogFile" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
if ($WithWeb) {
|
||||||
|
Write-Host " Web log: $webLogFile" -ForegroundColor DarkGray
|
||||||
|
Write-Host " UI: $webUrl/" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
Write-Host " Stop: .\scripts\stop_netx.ps1" -ForegroundColor DarkGray
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (-not $Background) {
|
||||||
|
Write-Host "==> Foreground mode (no -Background): this terminal stays attached to the API." -ForegroundColor Yellow
|
||||||
|
Write-Host " Schedule/UME logs will keep printing here until you Ctrl+C." -ForegroundColor Yellow
|
||||||
|
Write-Host " For detach: .\scripts\start_netx.ps1 -Background -WithWeb" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
if ($WithWeb) {
|
if ($WithWeb) {
|
||||||
Write-Host "==> Starting Vite dev server in background (foreground API mode)"
|
Write-Host "==> Starting Vite dev server in background (foreground API mode)"
|
||||||
$webRoot = Join-Path $projectRoot "web"
|
$webRoot = Join-Path $projectRoot "web"
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,64 @@ class NeExecValidationTests(unittest.TestCase):
|
||||||
_validate_command("interface GigabitEthernet0/0")
|
_validate_command("interface GigabitEthernet0/0")
|
||||||
self.assertEqual(ctx.exception.detail, "command_not_allowed_prefix")
|
self.assertEqual(ctx.exception.detail, "command_not_allowed_prefix")
|
||||||
|
|
||||||
|
def test_linux_shell_allows_shell_commands(self) -> None:
|
||||||
|
for cmd in (
|
||||||
|
"ls -la /var/log",
|
||||||
|
"ip addr | grep eth0",
|
||||||
|
"systemctl status sshd",
|
||||||
|
"cat /etc/os-release && uname -a",
|
||||||
|
"df -h; free -m",
|
||||||
|
):
|
||||||
|
with self.subTest(cmd=cmd):
|
||||||
|
_validate_command(cmd, policy="linux_shell")
|
||||||
|
_validate_command(cmd, policy="unrestricted")
|
||||||
|
|
||||||
|
def test_effective_policy_forces_readonly_when_feature_off(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=False):
|
||||||
|
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:
|
||||||
|
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")
|
||||||
|
|
||||||
|
def test_require_writable_rejects_when_feature_off(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=False):
|
||||||
|
self.assertEqual(require_exec_policy_writable("readonly"), "readonly")
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
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:
|
||||||
|
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):
|
||||||
|
self.assertEqual(
|
||||||
|
require_exec_policy_writable("linux_shell", device_type="linux"),
|
||||||
|
"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")
|
||||||
|
|
||||||
|
def test_linux_shell_blocks_newline(self) -> None:
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
_validate_command("ls\nrm -rf /", policy="linux_shell")
|
||||||
|
self.assertEqual(ctx.exception.detail, "command_chars_not_allowed")
|
||||||
|
|
||||||
|
def test_readonly_still_blocks_linux_cmds(self) -> None:
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
_validate_command("ls -la", policy="readonly")
|
||||||
|
self.assertEqual(ctx.exception.detail, "command_not_allowed_prefix")
|
||||||
|
|
||||||
def test_blocks_newline_chained_show_and_configure(self) -> None:
|
def test_blocks_newline_chained_show_and_configure(self) -> None:
|
||||||
with self.assertRaises(HTTPException) as ctx:
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
_validate_command("show interface\nconfigure terminal")
|
_validate_command("show interface\nconfigure terminal")
|
||||||
|
|
@ -182,6 +240,16 @@ class NeExecRunTests(unittest.TestCase):
|
||||||
@patch("netx_api.ne_exec._collect_on_device", return_value="ok-output")
|
@patch("netx_api.ne_exec._collect_on_device", return_value="ok-output")
|
||||||
@patch("netx_api.ne_exec.resolve_cli_target")
|
@patch("netx_api.ne_exec.resolve_cli_target")
|
||||||
def test_execute_skips_device_when_any_command_invalid(self, resolve, collect, _configured) -> None:
|
def test_execute_skips_device_when_any_command_invalid(self, resolve, collect, _configured) -> None:
|
||||||
|
resolve.return_value = (
|
||||||
|
_ready_creds(),
|
||||||
|
{
|
||||||
|
"source": "managed",
|
||||||
|
"id": "ne-1",
|
||||||
|
"exec_policy": "readonly",
|
||||||
|
"name": "R2",
|
||||||
|
"ip_address": "192.168.0.128",
|
||||||
|
},
|
||||||
|
)
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
with self.assertRaises(HTTPException) as ctx:
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
execute_managed_ne_commands(
|
execute_managed_ne_commands(
|
||||||
|
|
@ -191,7 +259,54 @@ class NeExecRunTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(ctx.exception.detail, "command_blocked")
|
self.assertEqual(ctx.exception.detail, "command_blocked")
|
||||||
collect.assert_not_called()
|
collect.assert_not_called()
|
||||||
resolve.assert_not_called()
|
resolve.assert_called_once()
|
||||||
|
|
||||||
|
@patch("netx_api.ne_exec.credentials_configured", return_value=True)
|
||||||
|
@patch("netx_api.ne_exec._collect_on_device", return_value="shell-ok")
|
||||||
|
@patch("netx_api.ne_exec.resolve_cli_target")
|
||||||
|
def test_execute_linux_shell_policy_allows_shell(self, resolve, collect, _configured) -> None:
|
||||||
|
resolve.return_value = (
|
||||||
|
_ready_creds(),
|
||||||
|
{
|
||||||
|
"source": "managed",
|
||||||
|
"id": "linux-1",
|
||||||
|
"exec_policy": "linux_shell",
|
||||||
|
"name": "lab",
|
||||||
|
"device_type": "linux",
|
||||||
|
"ip_address": "10.0.0.9",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db = MagicMock()
|
||||||
|
with patch("netx_api.config.settings") as mock_settings:
|
||||||
|
mock_settings.ne_exec_policy_enabled = True
|
||||||
|
out = execute_managed_ne_commands(db, ["ls -la /tmp"], ne_id="linux-1")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
self.assertEqual(out["output"], "shell-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")
|
||||||
|
def test_execute_ignores_db_policy_when_feature_off(self, resolve, collect, _configured) -> None:
|
||||||
|
resolve.return_value = (
|
||||||
|
_ready_creds(),
|
||||||
|
{
|
||||||
|
"source": "managed",
|
||||||
|
"id": "linux-1",
|
||||||
|
"exec_policy": "linux_shell",
|
||||||
|
"name": "lab",
|
||||||
|
"device_type": "linux",
|
||||||
|
"ip_address": "10.0.0.9",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db = MagicMock()
|
||||||
|
with patch("netx_api.config.settings") as mock_settings:
|
||||||
|
mock_settings.ne_exec_policy_enabled = False
|
||||||
|
with self.assertRaises(HTTPException) as ctx:
|
||||||
|
execute_managed_ne_commands(db, ["ls -la /tmp"], ne_id="linux-1")
|
||||||
|
self.assertEqual(ctx.exception.detail, "command_not_allowed_prefix")
|
||||||
|
collect.assert_not_called()
|
||||||
|
|
||||||
@patch("netx_api.ne_exec.credentials_configured", return_value=True)
|
@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._collect_on_device", return_value="ok-output")
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
buildManagedNeSaveBody,
|
buildManagedNeSaveBody,
|
||||||
emptyManagedNeForm,
|
emptyManagedNeForm,
|
||||||
formFromManagedNe,
|
formFromManagedNe,
|
||||||
|
isLinuxDeviceType,
|
||||||
type ManagedNeFormState,
|
type ManagedNeFormState,
|
||||||
} from "./formState";
|
} from "./formState";
|
||||||
|
|
||||||
|
|
@ -56,6 +57,7 @@ export function ManagedNeFormDialog({
|
||||||
}, [open, editingId]);
|
}, [open, editingId]);
|
||||||
|
|
||||||
const vendors = metaQuery.data?.vendors ?? [];
|
const vendors = metaQuery.data?.vendors ?? [];
|
||||||
|
const execPolicyEnabled = Boolean(metaQuery.data?.exec_policy_enabled);
|
||||||
const deviceTypes = useMemo(() => {
|
const deviceTypes = useMemo(() => {
|
||||||
const base = metaQuery.data?.device_types ?? [];
|
const base = metaQuery.data?.device_types ?? [];
|
||||||
const cur = String(form.device_type || "").trim();
|
const cur = String(form.device_type || "").trim();
|
||||||
|
|
@ -70,6 +72,7 @@ export function ManagedNeFormDialog({
|
||||||
hopHostRequired: t("managedNe.hop.hostRequired"),
|
hopHostRequired: t("managedNe.hop.hostRequired"),
|
||||||
hopUserRequired: t("managedNe.hop.userRequired"),
|
hopUserRequired: t("managedNe.hop.userRequired"),
|
||||||
hopPasswordRequired: t("managedNe.hop.passwordRequired"),
|
hopPasswordRequired: t("managedNe.hop.passwordRequired"),
|
||||||
|
execPolicyEnabled: Boolean(metaQuery.data?.exec_policy_enabled),
|
||||||
});
|
});
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return updateManagedNe(editing.id, body);
|
return updateManagedNe(editing.id, body);
|
||||||
|
|
@ -131,7 +134,14 @@ export function ManagedNeFormDialog({
|
||||||
label={t("managedNe.col.deviceType")}
|
label={t("managedNe.col.deviceType")}
|
||||||
required
|
required
|
||||||
value={form.device_type}
|
value={form.device_type}
|
||||||
onChange={(e) => setForm({ ...form, device_type: e.target.value })}
|
onChange={(e) => {
|
||||||
|
const device_type = e.target.value;
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
device_type,
|
||||||
|
exec_policy: isLinuxDeviceType(device_type) ? prev.exec_policy : "readonly",
|
||||||
|
}));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{deviceTypes.map((dt) => (
|
{deviceTypes.map((dt) => (
|
||||||
<option key={dt} value={dt}>
|
<option key={dt} value={dt}>
|
||||||
|
|
@ -139,6 +149,29 @@ export function ManagedNeFormDialog({
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
|
{execPolicyEnabled ? (
|
||||||
|
<>
|
||||||
|
<FieldSelect
|
||||||
|
label={t("managedNe.col.execPolicy")}
|
||||||
|
value={form.exec_policy}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
exec_policy: e.target.value as ManagedNeFormState["exec_policy"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="readonly">{t("managedNe.execPolicy.readonly")}</option>
|
||||||
|
{isLinuxDeviceType(form.device_type) ? (
|
||||||
|
<>
|
||||||
|
<option value="linux_shell">{t("managedNe.execPolicy.linuxShell")}</option>
|
||||||
|
<option value="unrestricted">{t("managedNe.execPolicy.unrestricted")}</option>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</FieldSelect>
|
||||||
|
<p className="form-field-hint form-grid__full">{t("managedNe.execPolicy.hint")}</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
isRequired
|
isRequired
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export type ManagedNeFormState = {
|
||||||
hop_vrf: string;
|
hop_vrf: string;
|
||||||
hop_target_auth_mode: "bastion_managed" | "manual";
|
hop_target_auth_mode: "bastion_managed" | "manual";
|
||||||
hop_enter_system_view: boolean;
|
hop_enter_system_view: boolean;
|
||||||
|
exec_policy: "readonly" | "linux_shell" | "unrestricted";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function deviceTypeForVendor(vendor: string): string {
|
export function deviceTypeForVendor(vendor: string): string {
|
||||||
|
|
@ -38,6 +39,13 @@ export function deviceTypeForVendor(vendor: string): string {
|
||||||
return "generic";
|
return "generic";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isLinuxDeviceType(deviceType: string | undefined | null): boolean {
|
||||||
|
const low = String(deviceType || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return low === "linux" || low === "linux_ssh" || low === "linux_telnet" || low.startsWith("linux_");
|
||||||
|
}
|
||||||
|
|
||||||
export function emptyManagedNeForm(): ManagedNeFormState {
|
export function emptyManagedNeForm(): ManagedNeFormState {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
|
|
@ -61,6 +69,7 @@ export function emptyManagedNeForm(): ManagedNeFormState {
|
||||||
hop_vrf: "",
|
hop_vrf: "",
|
||||||
hop_target_auth_mode: "bastion_managed",
|
hop_target_auth_mode: "bastion_managed",
|
||||||
hop_enter_system_view: false,
|
hop_enter_system_view: false,
|
||||||
|
exec_policy: "readonly",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,6 +121,10 @@ export function formFromManagedNe(row: ManagedNeItem): ManagedNeFormState {
|
||||||
hop_vrf: row.hop_vrf,
|
hop_vrf: row.hop_vrf,
|
||||||
hop_target_auth_mode: row.hop_target_auth_mode === "manual" ? "manual" : "bastion_managed",
|
hop_target_auth_mode: row.hop_target_auth_mode === "manual" ? "manual" : "bastion_managed",
|
||||||
hop_enter_system_view: Boolean(row.hop_enter_system_view),
|
hop_enter_system_view: Boolean(row.hop_enter_system_view),
|
||||||
|
exec_policy:
|
||||||
|
row.exec_policy === "linux_shell" || row.exec_policy === "unrestricted"
|
||||||
|
? row.exec_policy
|
||||||
|
: "readonly",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,7 +140,14 @@ export function managedSourceKey(
|
||||||
/** Build API body + hop validation. Throws Error with message key text already translated by caller. */
|
/** Build API body + hop validation. Throws Error with message key text already translated by caller. */
|
||||||
export function buildManagedNeSaveBody(
|
export function buildManagedNeSaveBody(
|
||||||
form: ManagedNeFormState,
|
form: ManagedNeFormState,
|
||||||
opts: { editing: boolean; hopHostRequired: string; hopUserRequired: string; hopPasswordRequired: string },
|
opts: {
|
||||||
|
editing: boolean;
|
||||||
|
hopHostRequired: string;
|
||||||
|
hopUserRequired: string;
|
||||||
|
hopPasswordRequired: string;
|
||||||
|
/** When false, omit exec_policy so lab-open cannot be persisted accidentally. */
|
||||||
|
execPolicyEnabled?: boolean;
|
||||||
|
},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
|
|
@ -152,6 +172,9 @@ export function buildManagedNeSaveBody(
|
||||||
...(form.password ? { password: form.password } : {}),
|
...(form.password ? { password: form.password } : {}),
|
||||||
...(form.hop_password ? { hop_password: form.hop_password } : {}),
|
...(form.hop_password ? { hop_password: form.hop_password } : {}),
|
||||||
};
|
};
|
||||||
|
if (opts.execPolicyEnabled) {
|
||||||
|
body.exec_policy = isLinuxDeviceType(form.device_type) ? form.exec_policy : "readonly";
|
||||||
|
}
|
||||||
if (form.hop_enabled) {
|
if (form.hop_enabled) {
|
||||||
if (!form.hop_host.trim()) throw new Error(opts.hopHostRequired);
|
if (!form.hop_host.trim()) throw new Error(opts.hopHostRequired);
|
||||||
if (!form.hop_username.trim()) throw new Error(opts.hopUserRequired);
|
if (!form.hop_username.trim()) throw new Error(opts.hopUserRequired);
|
||||||
|
|
|
||||||
|
|
@ -495,6 +495,7 @@ export const fetchManagedNeMeta = () =>
|
||||||
device_types: types.device_types,
|
device_types: types.device_types,
|
||||||
vendors: types.vendors,
|
vendors: types.vendors,
|
||||||
credentials_configured: creds.configured,
|
credentials_configured: creds.configured,
|
||||||
|
exec_policy_enabled: Boolean(types.exec_policy_enabled),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export type ManagedNeStats = {
|
export type ManagedNeStats = {
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,8 @@ export type ManagedNeItem = {
|
||||||
hop_vrf: string;
|
hop_vrf: string;
|
||||||
hop_target_auth_mode: string;
|
hop_target_auth_mode: string;
|
||||||
hop_enter_system_view?: boolean;
|
hop_enter_system_view?: boolean;
|
||||||
|
/** MCP/API CLI gate: readonly | linux_shell | unrestricted */
|
||||||
|
exec_policy?: "readonly" | "linux_shell" | "unrestricted";
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
@ -225,6 +227,8 @@ export type ManagedNeListResponse = {
|
||||||
export type ManagedNeMeta = {
|
export type ManagedNeMeta = {
|
||||||
device_types: string[];
|
device_types: string[];
|
||||||
vendors: string[];
|
vendors: string[];
|
||||||
|
/** When false (default), exec_policy UI/API open policies are disabled. */
|
||||||
|
exec_policy_enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ManagedNeImportResult = {
|
export type ManagedNeImportResult = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue