mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
497a5e048e
commit
8915455b52
12 changed files with 783 additions and 40 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue