mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 22:20:58 +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
|
||||
# Managed NE CLI: max commands per exec request (default 5, hard cap 50). Lab can set 10+.
|
||||
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_PORT=8890
|
||||
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``."""
|
||||
assert_secure_defaults_or_exit()
|
||||
_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)
|
||||
|
||||
alembic_ok = True
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||
from .device_types import SUPPORTED_DEVICE_TYPES
|
||||
from .models import CliConnectProfile, ManagedNE, UmeCliOverride, UmeInventoryNE
|
||||
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
|
||||
|
||||
_BUILTIN_NE_TYPE_RULES: list[tuple[re.Pattern[str], str, str]] = [
|
||||
|
|
@ -141,6 +142,7 @@ def resolve_cli_target(
|
|||
"port": meta["port"],
|
||||
"protocol": meta["protocol"],
|
||||
"connect_status": meta["connect_status"],
|
||||
"exec_policy": normalize_exec_policy(meta.get("exec_policy")),
|
||||
"hop_enabled": meta["hop_enabled"],
|
||||
"hop_vendor": meta["hop_vendor"],
|
||||
}
|
||||
|
|
@ -198,6 +200,7 @@ def resolve_cli_target(
|
|||
"port": int(profile.port or 22),
|
||||
"protocol": str(profile.protocol or "ssh"),
|
||||
"connect_status": connect_status,
|
||||
"exec_policy": EXEC_POLICY_READONLY,
|
||||
"hop_enabled": bool(profile.hop_enabled),
|
||||
"hop_vendor": str(profile.hop_vendor or ""),
|
||||
"cli_profile_id": str(profile.id),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
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):
|
||||
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"
|
||||
host: str = "127.0.0.1"
|
||||
|
|
@ -114,6 +124,8 @@ class Settings(BaseSettings):
|
|||
biz_state_heavy_workers: int = 4
|
||||
# Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec).
|
||||
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_max_sessions: int = 40
|
||||
# Per-user cap (0 = unlimited beyond global max).
|
||||
|
|
|
|||
|
|
@ -75,9 +75,14 @@ def api_list_managed_ne(
|
|||
@router.get("/meta/device-types")
|
||||
def api_device_types():
|
||||
# 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
|
||||
|
||||
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")
|
||||
|
|
@ -150,7 +155,7 @@ def api_exec_managed_ne(
|
|||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
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)
|
||||
out = execute_managed_ne_commands(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ class ManagedNE(Base):
|
|||
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||
vendor: Mapped[str] = mapped_column(String(64), default="Other", index=True)
|
||||
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.
|
||||
# Inventory create/update still enforces uniqueness in ne_service.
|
||||
ip_address: Mapped[str] = mapped_column(String(128), index=True)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ from .config import settings
|
|||
from .db import SessionLocal
|
||||
from .ne_collect_runner import _collect_on_device
|
||||
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_OUTPUT = 32_000
|
||||
|
|
@ -28,6 +33,8 @@ __all__ = [
|
|||
"_validate_command",
|
||||
"execute_managed_ne_commands",
|
||||
"execute_managed_ne_commands_batch",
|
||||
"effective_exec_policy",
|
||||
"normalize_exec_policy",
|
||||
"validate_ne_exec_command",
|
||||
]
|
||||
|
||||
|
|
@ -62,10 +69,16 @@ def execute_managed_ne_commands(
|
|||
max_cmds = _exec_max_commands()
|
||||
if len(cmds) > max_cmds:
|
||||
raise HTTPException(status_code=400, detail=f"too_many_commands (max {max_cmds})")
|
||||
for c in cmds:
|
||||
validate_ne_exec_command(c)
|
||||
|
||||
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)
|
||||
if skip:
|
||||
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
|
||||
|
||||
|
|
@ -6,6 +13,13 @@ 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}
|
||||
)
|
||||
|
||||
# Block obvious config-change / destructive patterns (case-insensitive).
|
||||
_BLOCKED_RE = re.compile(
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
if "|" not in cmd:
|
||||
return
|
||||
|
|
@ -52,13 +109,14 @@ def _validate_pipe_segments(cmd: str) -> None:
|
|||
raise HTTPException(status_code=400, detail="command_pipe_not_allowed")
|
||||
|
||||
|
||||
def validate_ne_exec_command(command: str) -> 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")
|
||||
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")
|
||||
if any(sep in cmd for sep in _FORBIDDEN_LINE_SEPARATORS):
|
||||
raise HTTPException(status_code=400, detail="command_chars_not_allowed")
|
||||
|
||||
|
||||
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):
|
||||
|
|
@ -70,5 +128,20 @@ def validate_ne_exec_command(command: str) -> None:
|
|||
_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.
|
||||
_validate_command = validate_ne_exec_command
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ from typing import Literal
|
|||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from .device_types import SUPPORTED_VENDORS
|
||||
from .ne_exec_guard import EXEC_POLICIES, EXEC_POLICY_READONLY
|
||||
|
||||
ConnectStatus = Literal["unknown", "testing", "pass", "fail"]
|
||||
ExecPolicy = Literal["readonly", "linux_shell", "unrestricted"]
|
||||
|
||||
|
||||
class ManagedNeCreate(BaseModel):
|
||||
|
|
@ -21,6 +23,7 @@ class ManagedNeCreate(BaseModel):
|
|||
password: str = ""
|
||||
tags: str = ""
|
||||
remark: str = ""
|
||||
exec_policy: ExecPolicy = "readonly"
|
||||
hop_enabled: bool = False
|
||||
hop_vendor: str = "zte"
|
||||
hop_host: str = ""
|
||||
|
|
@ -44,6 +47,16 @@ class ManagedNeCreate(BaseModel):
|
|||
return item
|
||||
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):
|
||||
name: str | None = None
|
||||
|
|
@ -56,6 +69,7 @@ class ManagedNeUpdate(BaseModel):
|
|||
password: str | None = None
|
||||
tags: str | None = None
|
||||
remark: str | None = None
|
||||
exec_policy: ExecPolicy | None = None
|
||||
hop_enabled: bool | None = None
|
||||
hop_vendor: str | None = None
|
||||
hop_host: str | None = None
|
||||
|
|
@ -81,6 +95,18 @@ class ManagedNeUpdate(BaseModel):
|
|||
return item
|
||||
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):
|
||||
id: str
|
||||
|
|
@ -102,6 +128,7 @@ class ManagedNeOut(BaseModel):
|
|||
# Provenance: "" | ume_sync | webcrt | lldp | …
|
||||
source: str = ""
|
||||
source_ref: str = ""
|
||||
exec_policy: ExecPolicy = "readonly"
|
||||
hop_enabled: bool = False
|
||||
hop_vendor: str = "zte"
|
||||
hop_host: str = ""
|
||||
|
|
@ -121,7 +148,7 @@ class ConnectTestRequest(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
|
||||
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_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_exec_guard import normalize_exec_policy
|
||||
from .timeutil import utcnow_naive
|
||||
|
||||
IMPORT_COLUMNS = (
|
||||
|
|
@ -241,6 +242,7 @@ def row_to_out(row: ManagedNE) -> ManagedNeOut:
|
|||
remark=str(row.remark or ""),
|
||||
source=str(row.source 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_vendor=str(row.hop_vendor or "zte"),
|
||||
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),
|
||||
"enable_secret": decrypt_secret(row.enable_secret_enc),
|
||||
"name": str(row.name or ""),
|
||||
"exec_policy": normalize_exec_policy(getattr(row, "exec_policy", None)),
|
||||
"hop_enabled": hop_enabled,
|
||||
"hop_vendor": str(row.hop_vendor or "zte"),
|
||||
"hop_host": str(row.hop_host or ""),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ from .ne_service_common import (
|
|||
_validate_hop_on_create,
|
||||
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
|
||||
# (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(),
|
||||
source="",
|
||||
source_ref="",
|
||||
exec_policy=require_exec_policy_writable(
|
||||
getattr(body, "exec_policy", None),
|
||||
device_type=body.device_type,
|
||||
),
|
||||
created_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()
|
||||
if "remark" in data and data["remark"] is not None:
|
||||
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"]:
|
||||
_require_crypto()
|
||||
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:
|
||||
"""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(
|
||||
conn,
|
||||
"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,
|
||||
"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(
|
||||
|
|
@ -539,14 +543,48 @@ def apply_all_legacy_startup_ddl(engine: Engine) -> 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 alembic import command
|
||||
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]
|
||||
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")
|
||||
_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",
|
||||
"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). "
|
||||
"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. "
|
||||
"Many NEs (batch-first, server concurrency default 4, max 20): "
|
||||
"(1) same CLI on all → ne_ids[]/nms_ne_ids[] + shared commands; "
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ param(
|
|||
[int]$Port = 8890,
|
||||
[int]$WebPort = 5173,
|
||||
[switch]$SkipInstall = $false,
|
||||
[Alias("Bg")]
|
||||
[switch]$Background = $false,
|
||||
# Common typo for -Background (otherwise PowerShell ignores intent / or fails).
|
||||
[Parameter(DontShow)]
|
||||
[switch]$Backgtound = $false,
|
||||
[switch]$WithWeb = $false,
|
||||
# Keep collectors inside the API process (legacy). Default: split API + worker.
|
||||
[switch]$InlineSchedulers = $false
|
||||
|
|
@ -11,6 +15,11 @@ param(
|
|||
|
||||
$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
|
||||
Set-Location $projectRoot
|
||||
|
||||
|
|
@ -234,10 +243,28 @@ if ($Background) {
|
|||
Write-Host "Err = $webErrFile"
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
Write-Host "==> Starting Vite dev server in background (foreground API mode)"
|
||||
$webRoot = Join-Path $projectRoot "web"
|
||||
|
|
|
|||
|
|
@ -98,6 +98,64 @@ class NeExecValidationTests(unittest.TestCase):
|
|||
_validate_command("interface GigabitEthernet0/0")
|
||||
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:
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
_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.resolve_cli_target")
|
||||
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()
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
execute_managed_ne_commands(
|
||||
|
|
@ -191,7 +259,54 @@ class NeExecRunTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(ctx.exception.detail, "command_blocked")
|
||||
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._collect_on_device", return_value="ok-output")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
buildManagedNeSaveBody,
|
||||
emptyManagedNeForm,
|
||||
formFromManagedNe,
|
||||
isLinuxDeviceType,
|
||||
type ManagedNeFormState,
|
||||
} from "./formState";
|
||||
|
||||
|
|
@ -56,6 +57,7 @@ export function ManagedNeFormDialog({
|
|||
}, [open, editingId]);
|
||||
|
||||
const vendors = metaQuery.data?.vendors ?? [];
|
||||
const execPolicyEnabled = Boolean(metaQuery.data?.exec_policy_enabled);
|
||||
const deviceTypes = useMemo(() => {
|
||||
const base = metaQuery.data?.device_types ?? [];
|
||||
const cur = String(form.device_type || "").trim();
|
||||
|
|
@ -70,6 +72,7 @@ export function ManagedNeFormDialog({
|
|||
hopHostRequired: t("managedNe.hop.hostRequired"),
|
||||
hopUserRequired: t("managedNe.hop.userRequired"),
|
||||
hopPasswordRequired: t("managedNe.hop.passwordRequired"),
|
||||
execPolicyEnabled: Boolean(metaQuery.data?.exec_policy_enabled),
|
||||
});
|
||||
if (editing) {
|
||||
return updateManagedNe(editing.id, body);
|
||||
|
|
@ -131,7 +134,14 @@ export function ManagedNeFormDialog({
|
|||
label={t("managedNe.col.deviceType")}
|
||||
required
|
||||
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) => (
|
||||
<option key={dt} value={dt}>
|
||||
|
|
@ -139,6 +149,29 @@ export function ManagedNeFormDialog({
|
|||
</option>
|
||||
))}
|
||||
</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
|
||||
fullWidth
|
||||
isRequired
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type ManagedNeFormState = {
|
|||
hop_vrf: string;
|
||||
hop_target_auth_mode: "bastion_managed" | "manual";
|
||||
hop_enter_system_view: boolean;
|
||||
exec_policy: "readonly" | "linux_shell" | "unrestricted";
|
||||
};
|
||||
|
||||
export function deviceTypeForVendor(vendor: string): string {
|
||||
|
|
@ -38,6 +39,13 @@ export function deviceTypeForVendor(vendor: string): string {
|
|||
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 {
|
||||
return {
|
||||
name: "",
|
||||
|
|
@ -61,6 +69,7 @@ export function emptyManagedNeForm(): ManagedNeFormState {
|
|||
hop_vrf: "",
|
||||
hop_target_auth_mode: "bastion_managed",
|
||||
hop_enter_system_view: false,
|
||||
exec_policy: "readonly",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +121,10 @@ export function formFromManagedNe(row: ManagedNeItem): ManagedNeFormState {
|
|||
hop_vrf: row.hop_vrf,
|
||||
hop_target_auth_mode: row.hop_target_auth_mode === "manual" ? "manual" : "bastion_managed",
|
||||
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. */
|
||||
export function buildManagedNeSaveBody(
|
||||
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> {
|
||||
const body: Record<string, unknown> = {
|
||||
name: form.name,
|
||||
|
|
@ -152,6 +172,9 @@ export function buildManagedNeSaveBody(
|
|||
...(form.password ? { password: form.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_host.trim()) throw new Error(opts.hopHostRequired);
|
||||
if (!form.hop_username.trim()) throw new Error(opts.hopUserRequired);
|
||||
|
|
|
|||
|
|
@ -495,6 +495,7 @@ export const fetchManagedNeMeta = () =>
|
|||
device_types: types.device_types,
|
||||
vendors: types.vendors,
|
||||
credentials_configured: creds.configured,
|
||||
exec_policy_enabled: Boolean(types.exec_policy_enabled),
|
||||
}));
|
||||
|
||||
export type ManagedNeStats = {
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@ export type ManagedNeItem = {
|
|||
hop_vrf: string;
|
||||
hop_target_auth_mode: string;
|
||||
hop_enter_system_view?: boolean;
|
||||
/** MCP/API CLI gate: readonly | linux_shell | unrestricted */
|
||||
exec_policy?: "readonly" | "linux_shell" | "unrestricted";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
|
@ -225,6 +227,8 @@ export type ManagedNeListResponse = {
|
|||
export type ManagedNeMeta = {
|
||||
device_types: string[];
|
||||
vendors: string[];
|
||||
/** When false (default), exec_policy UI/API open policies are disabled. */
|
||||
exec_policy_enabled?: boolean;
|
||||
};
|
||||
|
||||
export type ManagedNeImportResult = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue