Improve WebCRT new-session flow for SecureCRT-like SSH/Telnet use.

Add quick-connect sessions with credential retry, raw interactive drivers, bootstrap banner replay, Huawei telnet prompt cleanup, and session rename/delete in the tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 22:52:31 +08:00
parent accd28c9a1
commit f6b399e03e
23 changed files with 3682 additions and 225 deletions

View file

@ -118,5 +118,8 @@ class CliTargetOut(BaseModel):
ne_type: str = ""
vendor: str = ""
device_type: str = ""
protocol: str = ""
username: str = ""
has_password: bool = False
connect_status: str = "unknown"
cli_profile_ready: bool = False

View file

@ -258,8 +258,10 @@ def list_cli_targets(
page: int = 1,
page_size: int = 50,
) -> dict[str, Any]:
from .device_types import WEBCRT_NE_SOURCE
src = str(source or "all").strip().lower()
if src not in ("managed", "ume", "all"):
if src not in ("managed", "ume", "webcrt", "all"):
raise HTTPException(status_code=400, detail="invalid_source")
ready = cli_profile_ready(db)
page = max(1, int(page or 1))
@ -267,15 +269,18 @@ def list_cli_targets(
offset = (page - 1) * page_size
kw = str(keyword or "").strip()
def _managed_item(row: Any) -> dict[str, Any]:
def _managed_item(row: Any, *, list_source: str = "managed") -> dict[str, Any]:
return CliTargetOut(
source="managed",
source=list_source,
id=str(row.id),
ume_ne_id=None,
name=str(row.name or row.ip_address),
ip_address=str(row.ip_address),
vendor=str(row.vendor),
device_type=str(row.device_type),
protocol=str(getattr(row, "protocol", "") or ""),
username=str(getattr(row, "username", "") or ""),
has_password=bool(str(getattr(row, "password_enc", "") or "").strip()),
connect_status=str(row.connect_status),
cli_profile_ready=True,
).model_dump()
@ -293,19 +298,27 @@ def list_cli_targets(
cli_profile_ready=ready,
).model_dump()
def _managed_query():
stmt = db.query(ManagedNE)
if kw:
like = f"%{kw}%"
stmt = stmt.filter(
ManagedNE.name.ilike(like)
| ManagedNE.ip_address.ilike(like)
| ManagedNE.username.ilike(like)
| ManagedNE.tags.ilike(like)
| ManagedNE.vendor.ilike(like)
| ManagedNE.device_type.ilike(like)
)
return stmt.order_by(ManagedNE.updated_at.desc())
def _apply_kw(stmt: Any) -> Any:
if not kw:
return stmt
like = f"%{kw}%"
return stmt.filter(
ManagedNE.name.ilike(like)
| ManagedNE.ip_address.ilike(like)
| ManagedNE.username.ilike(like)
| ManagedNE.tags.ilike(like)
| ManagedNE.vendor.ilike(like)
| ManagedNE.device_type.ilike(like)
)
def _inventory_managed_query():
# Asset inventory for WebCRT: exclude Quick-Connect (source=webcrt) rows.
stmt = db.query(ManagedNE).filter(ManagedNE.source != WEBCRT_NE_SOURCE)
return _apply_kw(stmt).order_by(ManagedNE.updated_at.desc())
def _webcrt_query():
stmt = db.query(ManagedNE).filter(ManagedNE.source == WEBCRT_NE_SOURCE)
return _apply_kw(stmt).order_by(ManagedNE.updated_at.desc())
def _ume_query():
stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin(
@ -323,10 +336,17 @@ def list_cli_targets(
return stmt.order_by(UmeInventoryNE.ne_id.asc())
if src == "managed":
mq = _managed_query()
mq = _inventory_managed_query()
total = int(mq.count())
rows = mq.offset(offset).limit(page_size).all()
items = [_managed_item(x) for x in rows]
items = [_managed_item(x, list_source="managed") for x in rows]
return {"items": items, "total": total, "page": page, "page_size": page_size}
if src == "webcrt":
wq = _webcrt_query()
total = int(wq.count())
rows = wq.offset(offset).limit(page_size).all()
items = [_managed_item(x, list_source="webcrt") for x in rows]
return {"items": items, "total": total, "page": page, "page_size": page_size}
if src == "ume":
@ -336,24 +356,40 @@ def list_cli_targets(
items = [_ume_item(inv, ov) for inv, ov in rows]
return {"items": items, "total": total, "page": page, "page_size": page_size}
# source=all: managed first, then UME, with correct cross-list pagination
mq = _managed_query()
# source=all: inventory managed → webcrt sessions → UME
mq = _inventory_managed_query()
wq = _webcrt_query()
uq = _ume_query()
m_total = int(mq.count())
w_total = int(wq.count())
u_total = int(uq.count())
total = m_total + u_total
total = m_total + w_total + u_total
items: list[dict[str, Any]] = []
if offset < m_total:
take = min(page_size, m_total - offset)
for row in mq.offset(offset).limit(take).all():
items.append(_managed_item(row))
need = page_size - len(items)
if need > 0 and u_total > 0:
for inv, ov in uq.offset(0).limit(need).all():
items.append(_ume_item(inv, ov))
remaining = page_size
cursor = offset
if cursor < m_total and remaining > 0:
take = min(remaining, m_total - cursor)
for row in mq.offset(cursor).limit(take).all():
items.append(_managed_item(row, list_source="managed"))
remaining -= take
cursor = 0
else:
u_off = offset - m_total
for inv, ov in uq.offset(u_off).limit(page_size).all():
cursor = max(0, cursor - m_total)
if remaining > 0:
if cursor < w_total:
take = min(remaining, w_total - cursor)
for row in wq.offset(cursor).limit(take).all():
items.append(_managed_item(row, list_source="webcrt"))
remaining -= take
cursor = 0
else:
cursor = max(0, cursor - w_total)
if remaining > 0:
for inv, ov in uq.offset(cursor).limit(remaining).all():
items.append(_ume_item(inv, ov))
return {"items": items, "total": total, "page": page, "page_size": page_size}

View file

@ -88,8 +88,8 @@ class Settings(BaseSettings):
webcrt_connect_timeout_sec: int = 90
webcrt_attach_timeout_sec: int = 60
webcrt_data_dir: str = "data/webcrt"
# SSH transport keepalive interval (seconds); 0 disables.
webcrt_keepalive_sec: int = 30
# SSH transport keepalive interval (seconds); 0 disables (default off).
webcrt_keepalive_sec: int = 0
# Device anti-idle CLI nudge (0 = off). Keep off: NEs close idle VTY themselves.
webcrt_anti_idle_sec: int = 0
webcrt_anti_idle_payload: str = " "

View file

@ -43,3 +43,9 @@ SUPPORTED_VENDORS: tuple[str, ...] = (
"Nokia",
"Other",
)
# WebCRT "New Session": inventory types + raw interactive hosts (generic/linux).
WEBCRT_DEVICE_TYPES: tuple[str, ...] = SUPPORTED_DEVICE_TYPES + ("linux", "generic")
# ManagedNE.source value for sessions created via WebCRT Quick Connect.
WEBCRT_NE_SOURCE = "webcrt"

View file

@ -975,6 +975,23 @@ def on_startup() -> None:
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source VARCHAR(64) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source_ref VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT DEFAULT ''")
# WebCRT sessions may share a host IP; uniqueness is enforced in ne_service for inventory only.
for stmt in (
"ALTER TABLE managed_ne DROP CONSTRAINT IF EXISTS managed_ne_ip_address_key",
"DROP INDEX IF EXISTS managed_ne_ip_address_key",
"DROP INDEX IF EXISTS ix_managed_ne_ip_address",
"DROP INDEX IF EXISTS sqlite_autoindex_managed_ne_1",
):
try:
conn.exec_driver_sql(stmt)
except Exception:
pass
try:
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_managed_ne_ip_address ON managed_ne (ip_address)"
)
except Exception:
pass
conn.exec_driver_sql(
"ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP"
)

View file

@ -278,7 +278,9 @@ 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="")
ip_address: Mapped[str] = mapped_column(String(128), unique=True, index=True)
# 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)
port: Mapped[int] = mapped_column(Integer, default=22)
protocol: Mapped[str] = mapped_column(String(16), default="ssh")
username: Mapped[str] = mapped_column(String(128), default="")

View file

@ -54,6 +54,18 @@ def format_cli_failure(exc: BaseException | str, transcript: str = "", *, limit:
"""Human/ops-facing failure message; promote auth rejects above Pattern/ReadTimeout."""
if isinstance(exc, BaseException):
exc_text = f"{type(exc).__name__}: {exc}"
# Paramiko/Netmiko auth exceptions may carry little/no message text.
try:
import paramiko
if isinstance(exc, paramiko.AuthenticationException):
detail = str(exc).strip() or type(exc).__name__
return f"auth_rejected: {detail}"[:limit]
except Exception:
pass
if "AuthenticationException" in type(exc).__name__:
detail = str(exc).strip() or type(exc).__name__
return f"auth_rejected: {detail}"[:limit]
else:
exc_text = str(exc or "")
combined = f"{exc_text}\n{transcript or ''}"

View file

@ -8,13 +8,22 @@ from typing import Any
def normalize_netmiko_device_type(device_type: str, protocol: str) -> str:
dt = str(device_type or "").strip()
proto = str(protocol or "ssh").strip().lower()
if "zte" in dt.lower():
low = dt.lower()
# Raw / SecureCRT-style sessions (WebCRT quick-connect stores device_type=generic).
if low in ("generic", "generic_ssh", "generic_telnet", "terminal_server", "generic_termserver"):
return "generic_telnet" if proto == "telnet" or "telnet" in low else "generic_termserver_ssh"
# Netmiko ships linux / linux_ssh but not linux_telnet — use generic_telnet.
if low in ("linux", "linux_ssh", "linux_telnet") or low.startswith("linux_"):
if proto == "telnet" or "telnet" in low:
return "generic_telnet"
return "linux_ssh"
if "zte" in low:
if dt == "zte":
return f"zte_zxros_{proto}"
if "telnet" not in dt and "ssh" not in dt:
if "telnet" not in low and "ssh" not in low:
return f"{dt}_{proto}"
return dt
if "telnet" not in dt and "ssh" not in dt:
if "telnet" not in low and "ssh" not in low:
return f"{dt}_{proto}"
return dt

View file

@ -10,7 +10,12 @@ from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy.orm import Session
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS
from .device_types import (
SUPPORTED_DEVICE_TYPES,
SUPPORTED_VENDORS,
WEBCRT_DEVICE_TYPES,
WEBCRT_NE_SOURCE,
)
from .models import ManagedNE, UmeInventoryNE
from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_secret, encrypt_secret
from .ne_schemas import (
@ -41,6 +46,10 @@ IMPORT_COLUMNS = (
UME_SYNC_SOURCE = "ume_sync"
UME_SYNC_TAG = "UME"
# Re-export for callers (WebCRT quick-connect).
WEBCRT_SOURCE = WEBCRT_NE_SOURCE
# Re-export for callers (WebCRT Quick Connect).
WEBCRT_SOURCE = WEBCRT_NE_SOURCE
_BUILTIN_NE_TYPE_RULES: list[tuple[re.Pattern[str], str, str]] = [
(re.compile(r"ZXR|ZXCTN|M6000|\bBN\b", re.I), "zte_zxros", "ZTE"),
(re.compile(r"NE40|CE\b|ATN|MA5800|OptiX", re.I), "huawei", "Huawei"),
@ -313,6 +322,190 @@ def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut:
return row_to_out(row)
def _normalize_webcrt_device_type(device_type: str) -> str:
dt = str(device_type or "").strip()
low = dt.lower()
if low in ("linux", "linux_ssh", "linux_telnet"):
return "linux"
if low in ("generic", "generic_ssh", "generic_telnet", "terminal_server", "generic_termserver"):
return "generic"
return dt
def upsert_webcrt_managed_ne(db: Session, body: ManagedNeCreate) -> tuple[ManagedNeOut, str]:
"""Create/update a WebCRT-origin NE, or reuse an existing inventory NE by IP.
Returns ``(ne_out, action)`` where action is ``created`` | ``updated`` | ``reused``.
"""
_require_crypto()
_validate_hop_on_create(body)
ip = _normalize_ip(body.ip_address)
if not ip:
raise HTTPException(status_code=400, detail="ip_address_required")
if not str(body.username or "").strip():
raise HTTPException(status_code=400, detail="cli_username_required")
device_type = _normalize_webcrt_device_type(body.device_type)
if device_type not in WEBCRT_DEVICE_TYPES:
raise HTTPException(status_code=400, detail="unsupported_device_type")
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
now = _now()
if existing is not None:
src = str(existing.source or "").strip()
if src != WEBCRT_NE_SOURCE:
# Do not overwrite inventory / UME-synced assets; just open them.
return row_to_out(existing), "reused"
existing.name = str(body.name or "").strip() or existing.name or ip
existing.vendor = _normalize_vendor(body.vendor) if str(body.vendor or "").strip() else (
"Other" if device_type == "linux" else existing.vendor
)
existing.device_type = device_type
existing.port = int(body.port or existing.port or 22)
existing.protocol = _normalize_protocol(body.protocol)
existing.username = str(body.username or "").strip()
if str(body.password or "").strip():
existing.password_enc = encrypt_secret(body.password)
elif not str(existing.password_enc or "").strip() and not (
body.hop_enabled
and _normalize_hop_vendor(body.hop_vendor) == "bastion"
and _normalize_hop_target_auth_mode(body.hop_target_auth_mode) == "bastion_managed"
):
raise HTTPException(status_code=400, detail="password_required")
existing.source = WEBCRT_NE_SOURCE
_apply_hop_create(existing, body)
existing.updated_at = now
db.commit()
db.refresh(existing)
return row_to_out(existing), "updated"
if not str(body.password or "").strip() and not (
body.hop_enabled
and _normalize_hop_vendor(body.hop_vendor) == "bastion"
and _normalize_hop_target_auth_mode(body.hop_target_auth_mode) == "bastion_managed"
):
raise HTTPException(status_code=400, detail="password_required")
vendor = _normalize_vendor(body.vendor)
if device_type == "linux" and not str(body.vendor or "").strip():
vendor = "Other"
row = ManagedNE(
name=str(body.name or "").strip() or ip,
vendor=vendor,
device_type=device_type,
ip_address=ip,
port=int(body.port or 22),
protocol=_normalize_protocol(body.protocol),
username=str(body.username or "").strip(),
password_enc=encrypt_secret(body.password) if str(body.password or "").strip() else "",
enable_secret_enc="",
connect_status="unknown",
tags=str(body.tags or "").strip(),
remark=str(body.remark or "").strip(),
source=WEBCRT_NE_SOURCE,
source_ref="",
created_at=now,
updated_at=now,
)
_apply_hop_create(row, body)
db.add(row)
db.commit()
db.refresh(row)
return row_to_out(row), "created"
def _next_webcrt_session_name(db: Session, base: str) -> str:
"""Return base, or ``base (1)``, ``base (2)``, … among WebCRT session names."""
root = str(base or "").strip() or "session"
rows = (
db.query(ManagedNE.name)
.filter(ManagedNE.source == WEBCRT_NE_SOURCE)
.all()
)
taken = {str(r[0] or "").strip() for r in rows if str(r[0] or "").strip()}
if root not in taken:
return root
n = 1
while f"{root} ({n})" in taken:
n += 1
return f"{root} ({n})"
def upsert_webcrt_session_host(
db: Session,
*,
name: str = "",
ip_address: str,
port: int = 22,
protocol: str = "ssh",
username: str = "",
password: str = "",
save_password: bool = False,
) -> tuple[ManagedNeOut, str]:
"""Create a WebCRT session host (linux, no hop). Always inserts a new row.
Same IP is allowed; session name auto-suffixes ``(1)``, ``(2)``, … on collision.
Telnet never persists a password. SSH persists password only when ``save_password``.
Returns ``(ne_out, \"created\")``.
"""
_require_crypto()
ip = _normalize_ip(ip_address)
if not ip:
raise HTTPException(status_code=400, detail="ip_address_required")
proto = _normalize_protocol(protocol)
user = str(username or "").strip()
pwd = str(password or "")
if proto == "ssh" and not user:
raise HTTPException(status_code=400, detail="cli_username_required")
if proto == "ssh" and save_password and not pwd.strip():
raise HTTPException(status_code=400, detail="password_required")
now = _now()
display_name = _next_webcrt_session_name(db, str(name or "").strip() or ip)
password_enc = ""
if proto == "ssh" and save_password and pwd.strip():
password_enc = encrypt_secret(pwd)
row = ManagedNE(
name=display_name,
vendor="Other",
# generic → Netmiko terminal_server: SSH auth then raw PTY (no linux session prep).
device_type="generic",
ip_address=ip,
port=int(port or (23 if proto == "telnet" else 22)),
protocol=proto,
username=user,
password_enc=password_enc,
enable_secret_enc="",
connect_status="unknown",
tags="",
remark="",
source=WEBCRT_NE_SOURCE,
source_ref="",
created_at=now,
updated_at=now,
)
db.add(row)
try:
db.commit()
except Exception as exc:
db.rollback()
# Stale unique index on ip_address → restart API after migration, or drop constraint manually.
from sqlalchemy.exc import IntegrityError
if isinstance(exc, IntegrityError):
raise HTTPException(
status_code=409,
detail="ip_address_conflict_restart_required",
) from exc
raise
db.refresh(row)
return row_to_out(row), "created"
def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> ManagedNeOut:
row = db.get(ManagedNE, ne_id)
if not row:

View file

@ -243,9 +243,10 @@ def _netmiko_driver_class(device_type: str) -> type:
def _interactive_driver_class(base_cls: type) -> type:
"""Subclass that skips Netmiko auto terminal-length / terminal-width commands.
"""Subclass for WebCRT: raw interactive PTY after transport auth (SecureCRT-like).
Collection and MCP exec need paging disabled; WebCRT must not inject those.
Skips Netmiko session prep (prompt discovery, terminal length/width, force RETURN)
so the channel is left for the user — not consumed by library automation.
"""
class _InteractiveSession(base_cls): # type: ignore[misc,valid-type]
@ -255,6 +256,17 @@ def _interactive_driver_class(base_cls: type) -> type:
def set_terminal_width(self, *args: Any, **kwargs: Any) -> str: # noqa: ANN401
return ""
def session_preparation(self) -> None:
return None
def _try_session_preparation(self, force_data: bool = True) -> None: # noqa: FBT001, FBT002
del force_data
try:
self.session_preparation()
except Exception:
self.disconnect()
raise
_InteractiveSession.__name__ = f"Interactive{getattr(base_cls, '__name__', 'Netmiko')}"
return _InteractiveSession

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
@ -37,9 +38,32 @@ class WebcrtSessionCreate(BaseModel):
cols: int = Field(default=80, ge=20, le=500)
rows: int = Field(default=24, ge=5, le=200)
encoding: str = Field(default="utf-8")
# SSH transport keepalive interval (seconds). None = server default; 0 = off.
keepalive_sec: int | None = Field(default=None, ge=0, le=600)
post_login_commands: list[str] = Field(default_factory=list)
# Default async so UI can open WS while connect runs; tests may force sync via service API.
async_connect: bool = Field(default=True)
# One-shot credentials (not written to DB).
username: str | None = None
password: str | None = None
class WebcrtQuickConnectBody(BaseModel):
"""SecureCRT-style: upsert session host then open a session."""
name: str = ""
ip_address: str
port: int = 22
protocol: str = "ssh"
username: str = ""
password: str = ""
save_password: bool = False
cols: int = Field(default=80, ge=20, le=500)
rows: int = Field(default=24, ge=5, le=200)
encoding: str = Field(default="utf-8")
keepalive_sec: int | None = Field(default=None, ge=0, le=600)
post_login_commands: list[str] = Field(default_factory=list)
async_connect: bool = Field(default=True)
class WebcrtSftpListBody(BaseModel):
@ -68,6 +92,13 @@ def api_list_sessions() -> dict[str, Any]:
return list_sessions()
@router.get("/meta/device-types")
def api_webcrt_device_types() -> dict[str, Any]:
from .device_types import SUPPORTED_VENDORS, WEBCRT_DEVICE_TYPES
return {"device_types": list(WEBCRT_DEVICE_TYPES), "vendors": list(SUPPORTED_VENDORS)}
@router.post("/sessions")
def api_create_session(
body: WebcrtSessionCreate,
@ -86,11 +117,82 @@ def api_create_session(
rows=body.rows,
client=_client_label(request=request),
encoding=body.encoding,
keepalive_sec=body.keepalive_sec,
post_login_commands=list(body.post_login_commands or [])[:20],
async_connect=bool(body.async_connect),
username_override=body.username,
password_override=body.password,
)
@router.post("/sessions/quick-connect")
def api_quick_connect(
body: WebcrtQuickConnectBody,
request: Request,
db: Session = Depends(get_db),
) -> dict[str, Any]:
from .ne_service import upsert_webcrt_session_host
proto = str(body.protocol or "ssh").strip().lower()
if proto not in ("ssh", "telnet"):
raise HTTPException(status_code=400, detail="invalid_protocol")
save_password = bool(body.save_password) and proto == "ssh"
ne_out, action = upsert_webcrt_session_host(
db,
name=body.name,
ip_address=body.ip_address,
port=body.port,
protocol=proto,
username=body.username,
password=body.password,
save_password=save_password,
)
# Pass SSH credentials as one-shot overrides (covers unsaved password + reused inventory).
pwd_override: str | None = None
user_override: str | None = None
if proto == "ssh":
user_override = str(body.username or "").strip() or None
if str(body.password or "").strip():
pwd_override = str(body.password)
# SSH with password: wait for auth so wrong credentials can re-prompt (SecureCRT-like).
wait_for_auth = proto == "ssh" and bool(pwd_override)
async_connect = bool(body.async_connect) and not wait_for_auth
try:
session = create_session(
db,
ne_id=ne_out.id,
cols=body.cols,
rows=body.rows,
client=_client_label(request=request),
encoding=body.encoding,
keepalive_sec=body.keepalive_sec,
post_login_commands=list(body.post_login_commands or [])[:20],
async_connect=async_connect,
username_override=user_override,
password_override=pwd_override,
)
except HTTPException as exc:
# NE row already exists; return it so the UI retries in place (no duplicate hosts).
if exc.status_code == 502 and proto == "ssh":
raise HTTPException(
status_code=502,
detail={
"error": "connect_failed",
"message": str(exc.detail or "connect_failed"),
"ne": ne_out.model_dump(mode="json"),
"ne_action": action,
"list_source": "webcrt",
},
) from exc
raise
return {
**session,
"ne": ne_out.model_dump(mode="json"),
"ne_action": action,
"list_source": "webcrt",
}
@router.delete("/sessions/{session_id}")
def api_close_session(session_id: str, request: Request) -> dict[str, Any]:
return close_session(session_id, reason="client_delete", client=_client_label(request=request))
@ -181,6 +283,8 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
{
"type": "status",
"state": "connecting" if sess.state == "connecting" else "connected",
"phase": "authenticating" if sess.state == "connecting" else "ready",
"message": "authenticating" if sess.state == "connecting" else "",
"session_id": sess.session_id,
"ne_id": sess.ne_id,
"ne_name": sess.ne_name,
@ -195,24 +299,61 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
}
)
# Wait for async connect without blocking the event loop.
# Wait for async connect without blocking the event loop; emit phase updates.
if sess.state == "connecting":
loop = asyncio.get_running_loop()
try:
await loop.run_in_executor(
None,
lambda: wait_session_ready(
session_id,
timeout=max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15,
),
)
sess = get_session(session_id) or sess
except HTTPException as exc:
await websocket.send_json(
{"type": "status", "state": "error", "message": str(exc.detail)}
)
await websocket.close(code=4502)
return
budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15
deadline = time.time() + budget
while True:
cur = get_session(session_id) or sess
if cur.state != "connecting":
sess = cur
break
elapsed = max(0.0, time.time() - float(cur.connect_started_at or time.time()))
phase = "authenticating" if elapsed < 6.0 else "waiting_prompt"
try:
await websocket.send_json(
{
"type": "status",
"state": "connecting",
"phase": phase,
"message": phase,
"elapsed_ms": int(elapsed * 1000),
"session_id": cur.session_id,
}
)
except Exception:
break
remaining = deadline - time.time()
if remaining <= 0:
await websocket.send_json(
{"type": "status", "state": "error", "message": "connect_timeout"}
)
await websocket.close(code=4502)
return
slice_timeout = min(1.0, max(0.2, remaining))
try:
await loop.run_in_executor(
None,
lambda t=slice_timeout: wait_session_ready(session_id, timeout=t),
)
sess = get_session(session_id) or cur
break
except HTTPException as exc:
if exc.status_code == 504:
# Slice timeout while still connecting — keep polling with progress.
continue
await websocket.send_json(
{"type": "status", "state": "error", "message": str(exc.detail)}
)
await websocket.close(code=4502)
return
except Exception as exc:
await websocket.send_json(
{"type": "status", "state": "error", "message": f"connect_failed:{exc}"}
)
await websocket.close(code=4502)
return
await websocket.send_json(
{

View file

@ -64,7 +64,7 @@ def uses_network_cli_keymap(device_type: str = "", vendor: str = "") -> bool:
blob = f"{device_type} {vendor}".strip().lower()
if not blob:
return True
for token in ("linux", "ubuntu", "centos", "debian", "redhat", "unix"):
for token in ("linux", "ubuntu", "centos", "debian", "redhat", "unix", "generic_telnet", "generic"):
if token in blob:
return False
return True
@ -158,10 +158,35 @@ def _looks_like_cli_prompt(text: str) -> bool:
s = str(text or "").rstrip()
if not s:
return False
# Buffer races can leave a stray ':' after Huawei ``<r1>`` (from prior ``[Y/N]:``).
if s.endswith(":") and ">" in s:
s = s[:-1].rstrip()
# Common network CLI prompts: <r1> [HUAWEI] Router# Router>
return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s))
def _looks_like_login_prompt(text: str) -> bool:
"""True when the transcript ends at Username:/Login:/Password: (interactive auth)."""
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
lines = [ln.strip() for ln in s.split("\n") if ln.strip()]
if not lines:
return False
last = lines[-1]
return bool(re.search(r"(?i)(user\s*name|login|password)\s*:\s*$", last))
def _looks_like_password_change_prompt(text: str) -> bool:
"""Huawei/VRP post-auth ``Change now? [Y/N]:`` (Netmiko already answers N)."""
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
lines = [ln.strip() for ln in s.split("\n") if ln.strip()]
if not lines:
return False
last = lines[-1]
return bool(re.search(r"(?i)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:\s*$", last)) or bool(
re.search(r"\[Y/N\]\s*:\s*$", last, flags=re.I)
)
# Cisco/Netmiko often yields "R2#R2#" when a sync Enter is appended without a newline.
_GLUED_PROMPT_RE = re.compile(r"(?<=[#>])(?=(?:[A-Za-z0-9][\w.\-:]{0,62})[#>])")
@ -188,47 +213,74 @@ def prepare_bootstrap_output(text: str) -> str:
Trailing newline would leave the cursor on a blank line so the first typed line
looks wrong; cursor should sit after the prompt like a real CRT.
"""
return normalize_cli_transcript(text)
s = normalize_cli_transcript(text)
# Drop a stray ':' glued onto Huawei ``<host>`` after ``[Y/N]:`` buffer races.
s = re.sub(r"(<[^\r\n>]+>):\s*$", r"\1", s)
return s
def _drain_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> None:
"""Discard leftover bytes on the live channel (SSH/Telnet) after login priming."""
def _capture_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> str:
"""Read leftover PTY bytes into text (banner/MOTD after SSH auth).
Interactive WebCRT skips Netmiko session_preparation, so the post-auth banner
often never lands in ``session_log`` and must be pulled from the live channel.
"""
chunks: list[str] = []
channel = getattr(conn, "remote_conn", None)
if channel is None:
try:
_drain_channel(conn, rounds=max(2, int(duration / 0.05)), wait=0.05)
return _drain_channel(conn, rounds=max(2, int(duration / 0.05)), wait=0.05)
except Exception:
pass
return
return ""
end = time.time() + max(0.1, float(duration))
while time.time() < end:
got = False
try:
if hasattr(channel, "recv_ready") and hasattr(channel, "recv") and channel.recv_ready():
channel.recv(65535)
got = True
elif hasattr(channel, "read_very_eager"):
# Paramiko SSH channel
if hasattr(channel, "recv_ready") and hasattr(channel, "recv"):
if channel.recv_ready():
raw = channel.recv(65535)
if raw:
got = True
if isinstance(raw, bytes):
chunks.append(raw.decode("utf-8", errors="replace"))
else:
chunks.append(str(raw))
# telnetlib-style
elif callable(getattr(channel, "read_very_eager", None)):
data = channel.read_very_eager()
if data:
got = True
if isinstance(data, bytes):
chunks.append(data.decode("utf-8", errors="replace"))
else:
chunks.append(str(data))
else:
part = conn.read_channel()
if part:
got = True
chunks.append(str(part))
except Exception:
break
if not got:
time.sleep(0.04)
return "".join(chunks)
def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool = False) -> None:
"""Send one RETURN after Netmiko login so the interactive channel is fully ready.
def _drain_raw_channel(conn: ConnectHandler, *, duration: float = 0.5) -> None:
"""Discard leftover bytes on the live channel (SSH/Telnet) after login priming."""
_capture_raw_channel(conn, duration=duration)
def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool = False) -> str:
"""Sync interactive channel after login; return captured banner/prompt text.
Skip the sync Enter when the login transcript already ends with a CLI prompt —
otherwise slow Cisco VMs accumulate duplicate ``R2#`` lines in the bootstrap.
"""
parts: list[str] = []
try:
_drain_raw_channel(conn, duration=0.15)
parts.append(_capture_raw_channel(conn, duration=0.25))
except Exception:
pass
if not already_prompted:
@ -238,15 +290,16 @@ def _prime_interactive_channel(conn: ConnectHandler, *, already_prompted: bool =
try:
conn.write_channel("\n")
except Exception:
return
return "".join(parts)
try:
_drain_channel(conn, rounds=6, wait=0.08)
parts.append(_drain_channel(conn, rounds=6, wait=0.08))
except Exception:
pass
try:
_drain_raw_channel(conn, duration=0.35)
parts.append(_capture_raw_channel(conn, duration=0.35))
except Exception:
pass
return "".join(parts)
def _is_prompt_only_echo(text: str, prompt_hint: str = "") -> bool:
@ -361,6 +414,7 @@ class WebcrtSession:
vendor: str = ""
cli_keymap: bool = True
encoding: str = "utf-8"
keepalive_sec: int = 0
conn: ConnectHandler | None = None
created_at: float = field(default_factory=time.time)
last_activity: float = field(default_factory=time.time)
@ -802,19 +856,25 @@ def _webcrt_creds_ready(creds: dict[str, Any]) -> bool:
Bastion-managed hops store the target password on the bastion side, so an empty
NE password is valid (same as connectivity test). Direct / manual / Linux hops
still require a target password.
still require a target password for SSH.
Telnet (no hop) allows empty username/password so the user can authenticate
interactively in the terminal (SecureCRT-style).
"""
if not str(creds.get("username") or "").strip():
return False
hop_enabled = bool(creds.get("hop_enabled"))
hop_vendor = str(creds.get("hop_vendor") or "").strip().lower()
auth_mode = str(creds.get("hop_target_auth_mode") or "bastion_managed").strip().lower()
protocol = str(creds.get("protocol") or "ssh").strip().lower()
if hop_enabled and hop_vendor == "bastion" and auth_mode == "bastion_managed":
return bool(
str(creds.get("hop_host") or "").strip()
and str(creds.get("hop_username") or "").strip()
and str(creds.get("hop_password") or "")
)
if protocol == "telnet" and not hop_enabled:
return True
if not str(creds.get("username") or "").strip():
return False
return bool(str(creds.get("password") or ""))
@ -835,6 +895,7 @@ def _finish_connect(
cols=sess.cols,
rows=sess.rows,
interactive=True,
keepalive=int(sess.keepalive_sec or 0),
)
except Exception as exc:
partial = _session_log_text(log_buf).strip()
@ -868,29 +929,57 @@ def _finish_connect(
pass
pre_log = _session_log_text(log_buf)
already_prompted = _looks_like_cli_prompt(pre_log)
_prime_interactive_channel(conn, already_prompted=already_prompted)
bootstrap = prepare_bootstrap_output(_session_log_text(log_buf))
if not bootstrap.strip():
try:
more = _drain_channel(conn, rounds=6, wait=0.08)
except Exception:
more = ""
if more:
bootstrap = prepare_bootstrap_output(more)
# Slow Cisco VMs leave extra prompt bytes; discard before the live reader starts.
# Pull post-auth banner/MOTD from the PTY. With interactive no-op session_preparation
# (generic_termserver), Netmiko session_log is often empty — do not discard these bytes.
try:
_drain_raw_channel(conn, duration=0.55)
early = _capture_raw_channel(conn, duration=0.35)
except Exception:
early = ""
seed = f"{pre_log}{early}"
already_prompted = _looks_like_cli_prompt(seed)
primed = ""
# Do not send Enter at Username:/Password: or Huawei password-change [Y/N]:
# (Netmiko telnet_login already answers password-change with "N").
if _looks_like_login_prompt(seed) or _looks_like_password_change_prompt(seed):
try:
primed = _capture_raw_channel(conn, duration=0.9)
except Exception:
primed = ""
else:
try:
primed = _prime_interactive_channel(conn, already_prompted=already_prompted)
except Exception:
primed = ""
combined = f"{seed}{primed}"
# Final settle: keep stragglers in bootstrap (normalize collapses duplicate prompts).
try:
combined += _capture_raw_channel(conn, duration=0.35)
except Exception:
pass
if not str(combined).strip():
try:
combined = _drain_channel(conn, rounds=6, wait=0.08)
except Exception:
combined = ""
bootstrap = prepare_bootstrap_output(combined)
# Discard lone punctuation left on the wire (would glue onto ``<r1>`` in xterm).
try:
leftover = _capture_raw_channel(conn, duration=0.12)
except Exception:
leftover = ""
if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}:
bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}")
hop_guard = get_cli_hop_guard(conn)
sess.conn = conn
sess.cli_hop_guard = bool(hop_guard)
sess.cli_hop_prompt = str((hop_guard or {}).get("hop_prompt") or "")
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
# Bootstrap already ends at a prompt → never nudge another Enter on WS attach.
sess.needs_live_prompt = not _looks_like_cli_prompt(bootstrap)
# Nudge Enter on WS attach only when we still need a shell prompt.
# Never when already at CLI prompt or Username:/Password: (would empty-submit login).
sess.needs_live_prompt = (
not _looks_like_cli_prompt(bootstrap) and not _looks_like_login_prompt(bootstrap)
)
sess.open_session_log()
if bootstrap:
sess.append_session_log(bootstrap if bootstrap.endswith("\n") else bootstrap + "\n")
@ -954,8 +1043,11 @@ def create_session(
rows: int = 24,
client: str = "",
encoding: str = "utf-8",
keepalive_sec: int | None = None,
post_login_commands: list[str] | None = None,
async_connect: bool = True,
username_override: str | None = None,
password_override: str | None = None,
) -> dict[str, Any]:
from .cli_resolve import resolve_cli_target
@ -975,6 +1067,19 @@ def create_session(
except Exception as exc:
raise HTTPException(status_code=400, detail=f"credential_error:{exc}") from exc
# One-shot credentials for SecureCRT-style "do not save password" / retry.
if username_override is not None and str(username_override).strip():
creds["username"] = str(username_override).strip()
if password_override is not None:
creds["password"] = str(password_override)
protocol = str(device.get("protocol") or creds.get("protocol") or "ssh").strip().lower()
creds["protocol"] = protocol
# Netmiko telnet drivers dislike a completely missing username; use a placeholder
# for the wire only (interactive login still happens in the terminal).
if protocol == "telnet" and not bool(creds.get("hop_enabled")) and not str(creds.get("username") or "").strip():
creds["username"] = "telnet"
if not _webcrt_creds_ready(creds):
raise HTTPException(status_code=400, detail="credentials_incomplete")
@ -985,11 +1090,14 @@ def create_session(
target_id = str(device.get("id") or mid or uid)
target_ip = str(device.get("ip_address") or "")
target_name = str(device.get("name") or target_ip)
protocol = str(device.get("protocol") or creds.get("protocol") or "ssh")
device_type = str(device.get("device_type") or creds.get("device_type") or "")
vendor = str(device.get("vendor") or creds.get("vendor") or "")
cli_keymap = uses_network_cli_keymap(device_type, vendor)
enc = _normalize_encoding(encoding)
if keepalive_sec is None:
ka = max(0, int(getattr(settings, "webcrt_keepalive_sec", 0) or 0))
else:
ka = max(0, min(600, int(keepalive_sec)))
sess = WebcrtSession(
session_id=session_id,
@ -1003,6 +1111,7 @@ def create_session(
vendor=vendor,
cli_keymap=cli_keymap,
encoding=enc,
keepalive_sec=ka,
state="connecting",
post_login_commands=list(post_login_commands or [])[:20],
)
@ -1057,6 +1166,7 @@ def create_session(
"cols": sess.cols,
"rows": sess.rows,
"encoding": enc,
"keepalive_sec": ka,
"state": sess.state,
"ws_path": f"/v1/webcrt/sessions/{session_id}/ws",
"cli_hop": bool(sess.cli_hop_guard),
@ -1153,6 +1263,7 @@ def list_sessions() -> dict[str, Any]:
"ne_ip": s.ne_ip,
"protocol": s.protocol,
"encoding": s.encoding,
"keepalive_sec": int(s.keepalive_sec or 0),
"state": s.state,
"attached": s.attached,
"created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(),
@ -1174,7 +1285,7 @@ def list_sessions() -> dict[str, Any]:
"total": len(items),
"max_sessions": max(1, int(settings.webcrt_max_sessions or 20)),
"idle_timeout_sec": max(60, int(settings.webcrt_idle_timeout_sec or 1800)),
"keepalive_sec": int(getattr(settings, "webcrt_keepalive_sec", 30) or 0),
"keepalive_sec": int(getattr(settings, "webcrt_keepalive_sec", 0) or 0),
"anti_idle_sec": int(getattr(settings, "webcrt_anti_idle_sec", 0) or 0),
"items": items,
}