Record WebCRT device login/commands and NE exec in operation audit.

Make session lifecycle and typed CLI lines searchable in audit_log with password redaction, and surface summaries plus device filters in the ops UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-01 21:36:44 +08:00
parent 497a5e048e
commit 8915455b52
12 changed files with 783 additions and 40 deletions

View file

@ -99,8 +99,14 @@ def enqueue_audit(
) -> None:
global _dropped
act = str(action or "")
# Always persist auth / security-relevant events.
if act.startswith("auth.") or act.startswith("users.") or act.startswith("api_tokens.") or act.startswith("webcrt."):
# Always persist auth / security / device-op events.
if (
act.startswith("auth.")
or act.startswith("users.")
or act.startswith("api_tokens.")
or act.startswith("webcrt.")
or act.startswith("ne.")
):
pass
elif act.startswith("http.") and not _sample_ok():
return

View file

@ -1,9 +1,13 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import Response
from sqlalchemy.orm import Session
from .auth_deps import AuthContext, require_user
from .auth_service import write_audit
from .db import get_db
from .device_types import SUPPORTED_VENDORS
from .ne_connect import schedule_connect_tests
@ -39,6 +43,16 @@ from .models import ManagedNE
router = APIRouter(prefix="/v1/managed-ne", tags=["managed-ne"])
def _actor(ctx: AuthContext | None = None, request: Request | None = None) -> tuple[str, str]:
if ctx is not None and ctx.user is not None:
return str(ctx.user.id or ""), str(ctx.user.username or "")
if request is not None:
user = getattr(request.state, "auth_user", None)
if user:
return str(getattr(user, "id", "") or ""), str(getattr(user, "username", "") or "")
return "", ""
@router.get("")
def api_list_managed_ne(
keyword: str | None = Query(default=None),
@ -131,24 +145,55 @@ def api_delete_ume_synced_managed_ne(db: Session = Depends(get_db)):
@router.post("/exec")
def api_exec_managed_ne(body: ManagedNeExecRequest, db: Session = Depends(get_db)):
def api_exec_managed_ne(
body: ManagedNeExecRequest,
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)."""
return execute_managed_ne_commands(
uid, uname = _actor(ctx)
out = execute_managed_ne_commands(
db,
body.commands,
ne_id=body.ne_id,
ume_ne_id=body.ume_ne_id,
read_timeout_sec=body.read_timeout_sec,
)
device = out.get("device") if isinstance(out.get("device"), dict) else {}
write_audit(
db,
action="ne.exec",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec",
status_code=200 if out.get("ok") else 502,
detail={
"ne_id": body.ne_id or "",
"ume_ne_id": body.ume_ne_id or "",
"ne_name": str(device.get("name") or device.get("ne_name") or ""),
"ne_ip": str(device.get("ip_address") or device.get("ip") or device.get("mgmt_ip") or ""),
"commands": list(out.get("commands") or body.commands or [])[:20],
"ok": bool(out.get("ok")),
"error": str(out.get("error") or "")[:500],
"output_len": len(str(out.get("output") or "")),
},
)
return out
@router.post("/exec-batch")
def api_exec_managed_ne_batch(body: ManagedNeExecBatchRequest):
def api_exec_managed_ne_batch(
body: ManagedNeExecBatchRequest,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
):
"""Run read-only CLI on many NEs concurrently (field multi-NE sweeps)."""
uid, uname = _actor(ctx)
targets = None
if body.targets:
targets = [t.model_dump() for t in body.targets]
return execute_managed_ne_commands_batch(
out = execute_managed_ne_commands_batch(
targets=targets,
ne_ids=body.ne_ids,
ume_ne_ids=body.ume_ne_ids,
@ -156,6 +201,33 @@ def api_exec_managed_ne_batch(body: ManagedNeExecBatchRequest):
read_timeout_sec=body.read_timeout_sec,
concurrency=body.concurrency,
)
items = out.get("items") if isinstance(out, dict) else None
ok_n = 0
fail_n = 0
if isinstance(items, list):
for row in items:
if isinstance(row, dict) and row.get("ok"):
ok_n += 1
else:
fail_n += 1
write_audit(
db,
action="ne.exec_batch",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-batch",
status_code=200,
detail={
"ne_ids": list(body.ne_ids or [])[:100],
"ume_ne_ids": list(body.ume_ne_ids or [])[:100],
"target_count": len(targets or []) + len(body.ne_ids or []) + len(body.ume_ne_ids or []),
"commands": list(body.commands or [])[:20],
"ok_count": ok_n,
"fail_count": fail_n,
},
)
return out
@router.post("/connect-test")

View file

@ -468,7 +468,86 @@ def read_session_log_tail(session_id: str, *, max_bytes: int = 49152) -> str:
return ""
# Lifecycle + command events also land in audit_log (ops UI). Attach/detach/sftp stay file-only.
_DB_AUDIT_EVENTS = frozenset(
{
"session_connecting",
"session_created",
"session_open_failed",
"session_closed",
"command",
}
)
_PASSWORD_PROMPT_RE = re.compile(
r"(?:enter\s+)?(?:password|密码|passwd)\s*[:>]\s*$",
re.IGNORECASE,
)
def looks_like_password_prompt(text: str) -> bool:
"""True when device stdout tail asks for a password (interactive auth)."""
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
# Drop ANSI so prompt detection is stable.
s = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\].*?\x07|\x1b.", "", s)
parts = [ln.strip() for ln in s.split("\n") if ln.strip()]
if not parts:
return False
return bool(_PASSWORD_PROMPT_RE.search(parts[-1]))
def feed_command_line_buffer(buf: str, data: str, *, max_line: int = 512) -> tuple[str, list[str]]:
"""Accumulate stdin into completed command lines (Enter / CR / LF).
Handles backspace, ignores most control chars, truncates over-long lines.
Returns ``(new_buffer, completed_lines)``.
"""
cur = str(buf or "")
completed: list[str] = []
limit = max(64, min(int(max_line or 512), 4096))
for ch in str(data or ""):
if ch in ("\r", "\n"):
if cur:
completed.append(cur[:limit])
cur = ""
continue
if ch in ("\b", "\x7f"):
cur = cur[:-1] if cur else ""
continue
if ch == "\x03": # Ctrl-C — abandon current line
cur = ""
continue
if ord(ch) < 32 and ch != "\t":
continue
if len(cur) < limit:
cur += ch
return cur, completed
def _audit(event: str, **fields: Any) -> None:
"""Write WebCRT audit to jsonl; dual-write selected events into audit_log."""
# Enrich actor / device fields from the live session when callers omit them.
sid = str(fields.get("session_id") or "").strip()
if sid and (
not fields.get("owner_user_id")
or not fields.get("owner_username")
or not fields.get("ne_name")
or "protocol" not in fields
):
try:
from .webcrt_session_registry import get_session
sess = get_session(sid)
if sess is not None:
fields.setdefault("owner_user_id", sess.owner_user_id)
fields.setdefault("owner_username", sess.owner_username)
fields.setdefault("ne_id", sess.ne_id)
fields.setdefault("ne_name", sess.ne_name)
fields.setdefault("ne_ip", sess.ne_ip)
fields.setdefault("protocol", sess.protocol)
except Exception:
pass
record = {"ts": _utc_iso(), "event": event, **fields}
try:
path = webcrt_data_root() / "audit.jsonl"
@ -477,3 +556,35 @@ def _audit(event: str, **fields: Any) -> None:
except Exception:
_log.exception("webcrt audit write failed")
_log.info("webcrt.%s %s", event, {k: v for k, v in fields.items() if k != "detail"})
if str(event or "") not in _DB_AUDIT_EVENTS:
return
try:
from .audit_async import enqueue_audit
actor_uid = str(fields.get("owner_user_id") or fields.get("actor_user_id") or "")
actor_name = str(fields.get("owner_username") or fields.get("actor_username") or "")
detail = {
k: v
for k, v in fields.items()
if k
not in {
"owner_user_id",
"owner_username",
"actor_user_id",
"actor_username",
}
}
enqueue_audit(
action=f"webcrt.{event}",
actor_user_id=actor_uid,
actor_username=actor_name,
method="",
path=f"/v1/webcrt/sessions/{sid}" if sid else "/v1/webcrt",
status_code=0,
client_ip=str(fields.get("client_ip") or ""),
user_agent=str(fields.get("client") or "")[:512],
detail=detail,
)
except Exception:
_log.exception("webcrt audit_log enqueue failed event=%s", event)

View file

@ -18,10 +18,13 @@ from .ne_session_factory import (
)
from .webcrt_channel import (
_BoundedByteQueue,
_audit,
_decode_bytes,
_encode_text,
_session_log_path,
_utc_iso,
feed_command_line_buffer,
looks_like_password_prompt,
map_network_cli_enter,
map_network_cli_keys,
)
@ -87,6 +90,11 @@ class WebcrtSession:
sftp_ready: bool = False
_sftp: Any = field(default=None, repr=False)
_sftp_lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
# Interactive command audit: line buffer + password-prompt redaction.
_cmd_buf: str = field(default="", repr=False)
_cmd_buf_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
_password_mode: bool = field(default=False, repr=False)
_stdout_tail: str = field(default="", repr=False)
def touch(self) -> None:
self.last_activity = time.time()
@ -302,7 +310,7 @@ class WebcrtSession:
return "stale"
return chunk # bytes | None
def write_stdin(self, data: str) -> None:
def write_stdin(self, data: str, *, audit_source: str = "stdin") -> None:
if self.closed or self.conn is None:
raise RuntimeError("session_closed")
text = str(data or "")
@ -318,6 +326,7 @@ class WebcrtSession:
text = map_network_cli_enter(text, self.conn)
if not text:
return
self._note_stdin_for_audit(text, source=audit_source)
with self._write_lock:
# Prefer raw channel I/O for interactive typing (char echo / backspace).
channel = getattr(self.conn, "remote_conn", None)
@ -345,6 +354,43 @@ class WebcrtSession:
self.bytes_in += len(text)
self.touch()
def _note_stdin_for_audit(self, text: str, *, source: str = "stdin") -> None:
"""Extract completed command lines from stdin and emit webcrt.command audits."""
with self._cmd_buf_lock:
self._cmd_buf, lines = feed_command_line_buffer(self._cmd_buf, text)
redacted = bool(self._password_mode)
if redacted and lines:
self._password_mode = False
for cmd in lines:
if not str(cmd).strip():
continue
try:
_audit(
"command",
session_id=self.session_id,
ne_id=self.ne_id,
ne_name=self.ne_name,
ne_ip=self.ne_ip,
protocol=self.protocol,
owner_user_id=self.owner_user_id,
owner_username=self.owner_username,
command="***" if redacted else str(cmd)[:512],
redacted=bool(redacted),
source=str(source or "stdin")[:32],
)
except Exception:
_log.debug("webcrt command audit failed session=%s", self.session_id, exc_info=True)
def _note_stdout_for_audit(self, text: str) -> None:
"""Track device prompts so the next typed line can be redacted if it is a password."""
chunk = str(text or "")
if not chunk:
return
with self._cmd_buf_lock:
self._stdout_tail = (self._stdout_tail + chunk)[-4000:]
if looks_like_password_prompt(self._stdout_tail):
self._password_mode = True
def send_break(self) -> None:
"""Send SSH break / Telnet IAC BREAK to interrupt paging or hung commands."""
if self.closed or self.conn is None:
@ -472,7 +518,9 @@ class WebcrtSession:
self.bytes_out += len(chunk)
self.out_queue.put(chunk)
try:
self.append_session_log(_decode_bytes(chunk, self.encoding))
decoded = _decode_bytes(chunk, self.encoding)
self.append_session_log(decoded)
self._note_stdout_for_audit(decoded)
except Exception:
pass
if self.cli_hop_guard and self._note_cli_hop_output(chunk):
@ -516,7 +564,7 @@ class WebcrtSession:
return
for cmd in cmds[:20]:
try:
self.write_stdin(cmd + "\r")
self.write_stdin(cmd + "\r", audit_source="post_login")
time.sleep(0.15)
except Exception:
_log.debug("post_login command failed session=%s", self.session_id, exc_info=True)

View file

@ -261,8 +261,12 @@ def _finish_connect(
"session_open_failed",
session_id=sess.session_id,
ne_id=sess.ne_id,
ne_name=sess.ne_name,
ne_ip=sess.ne_ip,
protocol=sess.protocol,
source=str(device.get("source") or ""),
owner_user_id=sess.owner_user_id,
owner_username=sess.owner_username,
client=client or "",
error=str(exc)[:500],
transcript_len=len(partial),
@ -500,6 +504,8 @@ def _finish_connect(
protocol=sess.protocol,
encoding=sess.encoding,
source=str(device.get("source") or ""),
owner_user_id=sess.owner_user_id,
owner_username=sess.owner_username,
hop_enabled=bool(creds.get("hop_enabled")),
hop_vendor=str(creds.get("hop_vendor") or "") if creds.get("hop_enabled") else "",
cli_hop_guard=bool(hop_guard),
@ -608,9 +614,12 @@ def create_session(
"session_connecting",
session_id=session_id,
ne_id=sess.ne_id,
ne_name=sess.ne_name,
ne_ip=sess.ne_ip,
protocol=sess.protocol,
encoding=enc,
owner_user_id=sess.owner_user_id,
owner_username=sess.owner_username,
client=client or "",
async_connect=bool(async_connect),
)
@ -746,7 +755,11 @@ def close_session(session_id: str, *, reason: str = "closed", client: str = "")
"session_closed",
session_id=session_id,
ne_id=sess.ne_id,
ne_name=sess.ne_name,
ne_ip=sess.ne_ip,
protocol=sess.protocol,
owner_user_id=sess.owner_user_id,
owner_username=sess.owner_username,
reason=reason,
client=client or "",
bytes_in=sess.bytes_in,