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,

View file

@ -0,0 +1,78 @@
"""Device exec audit coverage for managed-ne /exec endpoints."""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from netx_api.auth_deps import AuthContext, require_user
from netx_api.db import get_db
from netx_api.managed_ne_router import router as managed_ne_router
from netx_api.models import AppUser
class ManagedNeExecAuditTests(unittest.TestCase):
def setUp(self) -> None:
self.app = FastAPI()
self.app.include_router(managed_ne_router)
fake = AppUser(id="u1", username="alice", role="admin", password_hash="x")
fake.is_active = True
def _user() -> AuthContext:
return AuthContext(user=fake, auth_via="disabled", scopes=frozenset({"ne:exec"}))
def _db():
yield MagicMock()
self.app.dependency_overrides[require_user] = _user
self.app.dependency_overrides[get_db] = _db
self.client = TestClient(self.app)
def tearDown(self) -> None:
self.app.dependency_overrides.clear()
@patch("netx_api.managed_ne_router.write_audit")
@patch("netx_api.managed_ne_router.execute_managed_ne_commands")
def test_exec_writes_audit(self, mock_exec: MagicMock, mock_audit: MagicMock) -> None:
mock_exec.return_value = {
"ok": True,
"device": {"name": "core-sw", "ip_address": "10.0.0.1"},
"commands": ["display version"],
"output": "VRP",
}
r = self.client.post(
"/v1/managed-ne/exec",
json={"ne_id": "ne1", "commands": ["display version"]},
)
self.assertEqual(r.status_code, 200)
mock_audit.assert_called_once()
kwargs = mock_audit.call_args.kwargs
self.assertEqual(kwargs["action"], "ne.exec")
self.assertEqual(kwargs["actor_username"], "alice")
self.assertEqual(kwargs["detail"]["ne_name"], "core-sw")
self.assertEqual(kwargs["detail"]["commands"], ["display version"])
@patch("netx_api.managed_ne_router.write_audit")
@patch("netx_api.managed_ne_router.execute_managed_ne_commands_batch")
def test_exec_batch_writes_audit(self, mock_batch: MagicMock, mock_audit: MagicMock) -> None:
mock_batch.return_value = {
"items": [{"ok": True}, {"ok": False}],
}
r = self.client.post(
"/v1/managed-ne/exec-batch",
json={"ne_ids": ["a", "b"], "commands": ["display clock"]},
)
self.assertEqual(r.status_code, 200)
mock_audit.assert_called_once()
kwargs = mock_audit.call_args.kwargs
self.assertEqual(kwargs["action"], "ne.exec_batch")
self.assertEqual(kwargs["detail"]["ok_count"], 1)
self.assertEqual(kwargs["detail"]["fail_count"], 1)
if __name__ == "__main__":
unittest.main()

View file

@ -44,7 +44,8 @@ class WebcrtServiceTests(unittest.TestCase):
def tearDown(self) -> None:
self.setUp()
def test_session_write_resize_and_close(self) -> None:
@patch("netx_api.webcrt_session_model._audit")
def test_session_write_resize_and_close(self, _mock_audit: MagicMock) -> None:
conn = _FakeConn()
sess = svc.WebcrtSession(
session_id="s1",
@ -342,6 +343,7 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertIn("<r1>", echo)
self.assertFalse(sess.needs_live_prompt)
before = list(fake.written)
with patch("netx_api.webcrt_session_model._audit"):
sess.write_stdin("\n")
self.assertEqual(fake.written[len(before) :], ["\n"])
svc.close_session(out["session_id"], reason="test")

208
tests/test_webcrt_audit.py Normal file
View file

@ -0,0 +1,208 @@
"""Tests for WebCRT / device operation audit (command lines + audit_log dual-write)."""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from netx_api.webcrt_channel import (
_DB_AUDIT_EVENTS,
_audit,
feed_command_line_buffer,
looks_like_password_prompt,
)
from netx_api.webcrt_session_model import WebcrtSession
class FeedCommandLineBufferTests(unittest.TestCase):
def test_enter_emits_line(self) -> None:
buf, lines = feed_command_line_buffer("", "display version\r")
self.assertEqual(buf, "")
self.assertEqual(lines, ["display version"])
def test_backspace(self) -> None:
buf, lines = feed_command_line_buffer("", "disX\x08play\n")
self.assertEqual(buf, "")
self.assertEqual(lines, ["display"])
def test_multiline_paste(self) -> None:
buf, lines = feed_command_line_buffer("", "a\nb\nc\n")
self.assertEqual(buf, "")
self.assertEqual(lines, ["a", "b", "c"])
def test_partial_stays_in_buffer(self) -> None:
buf, lines = feed_command_line_buffer("", "sho")
self.assertEqual(buf, "sho")
self.assertEqual(lines, [])
buf, lines = feed_command_line_buffer(buf, "w ver\n")
self.assertEqual(buf, "")
self.assertEqual(lines, ["show ver"])
def test_empty_line_skipped(self) -> None:
buf, lines = feed_command_line_buffer("", "\r\n")
self.assertEqual(buf, "")
self.assertEqual(lines, [])
def test_ctrl_c_clears(self) -> None:
buf, lines = feed_command_line_buffer("half", "\x03show\n")
self.assertEqual(buf, "")
self.assertEqual(lines, ["show"])
def test_truncates_long_line(self) -> None:
long = "x" * 600
buf, lines = feed_command_line_buffer("", long + "\n", max_line=512)
self.assertEqual(buf, "")
self.assertEqual(len(lines), 1)
self.assertEqual(len(lines[0]), 512)
class PasswordPromptTests(unittest.TestCase):
def test_detects_password_prompt(self) -> None:
self.assertTrue(looks_like_password_prompt("Password:"))
self.assertTrue(looks_like_password_prompt("Please enter password:"))
self.assertTrue(looks_like_password_prompt("请输入密码:"))
self.assertFalse(looks_like_password_prompt("<SW>"))
self.assertFalse(looks_like_password_prompt("Username:"))
class AuditDualWriteTests(unittest.TestCase):
def test_db_events_set(self) -> None:
self.assertIn("session_created", _DB_AUDIT_EVENTS)
self.assertIn("command", _DB_AUDIT_EVENTS)
self.assertNotIn("session_attached", _DB_AUDIT_EVENTS)
@patch("netx_api.webcrt_channel.webcrt_data_root")
@patch("netx_api.audit_async.enqueue_audit")
def test_lifecycle_enqueues_audit_log(self, mock_enq: MagicMock, mock_root: MagicMock) -> None:
root = MagicMock()
path = MagicMock()
mock_root.return_value = root
root.__truediv__ = MagicMock(return_value=path)
path.open = MagicMock()
fh = MagicMock()
path.open.return_value.__enter__ = MagicMock(return_value=fh)
path.open.return_value.__exit__ = MagicMock(return_value=False)
_audit(
"session_created",
session_id="sid1",
ne_id="ne1",
ne_name="core-sw",
ne_ip="10.0.0.1",
protocol="ssh",
owner_user_id="u1",
owner_username="alice",
)
mock_enq.assert_called_once()
kwargs = mock_enq.call_args.kwargs
self.assertEqual(kwargs["action"], "webcrt.session_created")
self.assertEqual(kwargs["actor_username"], "alice")
self.assertEqual(kwargs["actor_user_id"], "u1")
self.assertEqual(kwargs["detail"]["ne_name"], "core-sw")
self.assertEqual(kwargs["detail"]["ne_ip"], "10.0.0.1")
@patch("netx_api.webcrt_channel.webcrt_data_root")
@patch("netx_api.audit_async.enqueue_audit")
def test_attach_does_not_enqueue(self, mock_enq: MagicMock, mock_root: MagicMock) -> None:
root = MagicMock()
path = MagicMock()
mock_root.return_value = root
root.__truediv__ = MagicMock(return_value=path)
path.open = MagicMock()
fh = MagicMock()
path.open.return_value.__enter__ = MagicMock(return_value=fh)
path.open.return_value.__exit__ = MagicMock(return_value=False)
_audit("session_attached", session_id="sid1", ne_id="ne1")
mock_enq.assert_not_called()
class SessionCommandAuditTests(unittest.TestCase):
@patch("netx_api.webcrt_session_model._audit")
def test_write_stdin_audits_completed_command(self, mock_audit: MagicMock) -> None:
conn = MagicMock()
conn.RETURN = "\n"
conn.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
# Force write_channel path (no send).
del conn.remote_conn.send
conn.write_channel = MagicMock()
sess = WebcrtSession(
session_id="s-cmd",
ne_id="ne1",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
cli_keymap=False,
owner_user_id="u1",
owner_username="bob",
conn=conn,
)
sess.write_stdin("display version\r")
mock_audit.assert_called()
event = mock_audit.call_args[0][0]
self.assertEqual(event, "command")
kwargs = mock_audit.call_args.kwargs
self.assertEqual(kwargs["command"], "display version")
self.assertEqual(kwargs["owner_username"], "bob")
self.assertEqual(kwargs["ne_name"], "lab")
self.assertFalse(kwargs["redacted"])
@patch("netx_api.webcrt_session_model._audit")
def test_password_mode_redacts_command(self, mock_audit: MagicMock) -> None:
conn = MagicMock()
conn.RETURN = "\n"
conn.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
del conn.remote_conn.send
conn.write_channel = MagicMock()
sess = WebcrtSession(
session_id="s-pw",
ne_id="ne1",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
cli_keymap=False,
conn=conn,
)
sess._note_stdout_for_audit("Password:")
self.assertTrue(sess._password_mode)
sess.write_stdin("secret-pass\r")
kwargs = mock_audit.call_args.kwargs
self.assertEqual(kwargs["command"], "***")
self.assertTrue(kwargs["redacted"])
self.assertFalse(sess._password_mode)
@patch("netx_api.webcrt_session_model._audit")
def test_post_login_source(self, mock_audit: MagicMock) -> None:
conn = MagicMock()
conn.RETURN = "\n"
conn.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
del conn.remote_conn.send
conn.write_channel = MagicMock()
sess = WebcrtSession(
session_id="s-pl",
ne_id="ne1",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
cli_keymap=False,
conn=conn,
post_login_commands=["screen-length 0 temporary"],
)
with patch("netx_api.webcrt_session_model.time.sleep"):
sess.run_post_login_commands()
kwargs = mock_audit.call_args.kwargs
self.assertEqual(kwargs["source"], "post_login")
self.assertEqual(kwargs["command"], "screen-length 0 temporary")
if __name__ == "__main__":
unittest.main()

View file

@ -1867,6 +1867,24 @@ const en = {
audit: {
title: "Audit & ops",
logsTitle: "Operation logs",
colSummary: "Summary",
colDetail: "Detail",
showDetail: "Expand",
hideDetail: "Collapse",
filterAll: "All",
filterWebcrt: "Device terminal",
filterNeExec: "Device exec",
filterAuth: "Auth",
summary: {
connecting: "Connecting to {{device}}",
loginOk: "Logged in to {{device}}",
loginFail: "Login failed {{device}}: {{error}}",
logout: "Disconnected {{device}}",
logoutReason: "Disconnected {{device}} ({{reason}})",
command: "{{device}} · cmd: {{command}}",
neExec: "{{device}} · exec: {{commands}}",
neExecBatch: "Batch exec {{n}} NE(s) (ok {{ok}} / fail {{fail}})",
},
nav: {
tasks: "Task overview",
logs: "Operation logs",

View file

@ -1845,6 +1845,24 @@ const zh = {
audit: {
title: "操作审计",
logsTitle: "操作日志",
colSummary: "摘要",
colDetail: "详情",
showDetail: "展开",
hideDetail: "收起",
filterAll: "全部",
filterWebcrt: "设备终端",
filterNeExec: "设备执行",
filterAuth: "登录认证",
summary: {
connecting: "正在登录 {{device}}",
loginOk: "登录 {{device}}",
loginFail: "登录失败 {{device}}:{{error}}",
logout: "断开 {{device}}",
logoutReason: "断开 {{device}}({{reason}})",
command: "{{device}} · 命令: {{command}}",
neExec: "{{device}} · 执行: {{commands}}",
neExecBatch: "批量执行 {{n}} 台(成功 {{ok}} / 失败 {{fail}})",
},
nav: {
tasks: "任务概览",
logs: "操作日志",

View file

@ -10664,3 +10664,49 @@ option {
border-color: rgba(148, 163, 184, 0.2) !important;
color: #e2e8f0 !important;
}
/* Operation audit logs */
.system-page .audit-quick-filters button.is-active {
background: rgba(37, 99, 235, 0.18);
border-color: rgba(59, 130, 246, 0.55);
color: #1e3a8a;
font-weight: 600;
}
.system-page .audit-summary-cell {
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.system-page button.linkish {
background: transparent;
border: none;
color: #2563eb;
cursor: pointer;
padding: 0;
height: auto;
font: inherit;
text-decoration: underline;
}
.system-page .audit-detail-row td {
background: rgba(15, 23, 42, 0.04);
padding: 10px 12px 14px;
}
.system-page .audit-detail-pre {
margin: 0;
max-height: 240px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
}
.system-page .audit-detail-meta {
margin: 8px 0 0;
}

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n";
@ -17,6 +17,8 @@ type AuditItem = {
detail: Record<string, unknown>;
};
type QuickFilter = "" | "webcrt." | "ne.exec" | "auth.";
function statusClass(code: number): string {
if (code >= 500) return "pt-list-status--failed";
if (code >= 400) return "pt-list-status--warning";
@ -24,21 +26,89 @@ function statusClass(code: number): string {
return "pt-list-status--other";
}
function detailStr(detail: Record<string, unknown>, key: string): string {
const v = detail?.[key];
if (v == null) return "";
return String(v).trim();
}
function deviceLabel(detail: Record<string, unknown>): string {
const name = detailStr(detail, "ne_name");
const ip = detailStr(detail, "ne_ip");
if (name && ip) return `${name} (${ip})`;
return name || ip || detailStr(detail, "ne_id") || "";
}
export function auditSummary(
action: string,
detail: Record<string, unknown>,
t: (key: string, vars?: Record<string, string>) => string,
): string {
const d = detail || {};
const device = deviceLabel(d);
if (action === "webcrt.session_connecting") {
return t("audit.summary.connecting", { device: device || t("common.empty") });
}
if (action === "webcrt.session_created") {
return t("audit.summary.loginOk", { device: device || t("common.empty") });
}
if (action === "webcrt.session_open_failed") {
const err = detailStr(d, "error") || t("common.empty");
return t("audit.summary.loginFail", { device: device || t("common.empty"), error: err.slice(0, 80) });
}
if (action === "webcrt.session_closed") {
const reason = detailStr(d, "reason");
return reason
? t("audit.summary.logoutReason", { device: device || t("common.empty"), reason })
: t("audit.summary.logout", { device: device || t("common.empty") });
}
if (action === "webcrt.command") {
const cmd = detailStr(d, "command") || t("common.empty");
const redacted = Boolean(d.redacted);
return t("audit.summary.command", {
device: device || t("common.empty"),
command: redacted ? "***" : cmd,
});
}
if (action === "ne.exec") {
const cmds = Array.isArray(d.commands) ? d.commands.map(String).filter(Boolean) : [];
return t("audit.summary.neExec", {
device: device || t("common.empty"),
commands: cmds.slice(0, 3).join("; ") || t("common.empty"),
});
}
if (action === "ne.exec_batch") {
return t("audit.summary.neExecBatch", {
n: String(d.target_count ?? ((d.ne_ids as unknown[]) || []).length || 0),
ok: String(d.ok_count ?? 0),
fail: String(d.fail_count ?? 0),
});
}
if (action.startsWith("auth.")) {
return action.replace(/^auth\./, "");
}
return "";
}
export function AuditPage() {
const { t } = useI18n();
const { ready, isAdmin } = useAuth();
const [page, setPage] = useState(1);
const [username, setUsername] = useState("");
const [action, setAction] = useState("");
const [quick, setQuick] = useState<QuickFilter>("");
const [expanded, setExpanded] = useState<string | null>(null);
const effectiveAction = action.trim() || quick;
const query = useQuery({
queryKey: ["auditLogs", page, username, action],
queryKey: ["auditLogs", page, username, effectiveAction],
queryFn: () => {
const p = new URLSearchParams();
p.set("page", String(page));
p.set("page_size", "50");
if (username.trim()) p.set("username", username.trim());
if (action.trim()) p.set("action", action.trim());
if (effectiveAction) p.set("action", effectiveAction);
return apiGet<{ total: number; page: number; page_size: number; items: AuditItem[] }>(
`/v1/audit-logs?${p.toString()}`,
);
@ -49,7 +119,13 @@ export function AuditPage() {
const items = useMemo(() => query.data?.items || [], [query.data]);
const total = query.data?.total || 0;
const pages = Math.max(1, Math.ceil(total / 50));
const hasFilters = Boolean(username.trim() || action.trim());
const hasFilters = Boolean(username.trim() || action.trim() || quick);
const setQuickFilter = (next: QuickFilter) => {
setPage(1);
setQuick(next);
if (next) setAction("");
};
return (
<div className="page-stack system-page">
@ -60,6 +136,26 @@ export function AuditPage() {
<p className="panel__hint">{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}</p>
<div className="pt-list">
<div className="filter-inline audit-quick-filters">
{(
[
["", "audit.filterAll"],
["webcrt.", "audit.filterWebcrt"],
["ne.exec", "audit.filterNeExec"],
["auth.", "audit.filterAuth"],
] as const
).map(([value, labelKey]) => (
<button
key={value || "all"}
type="button"
className={quick === value && !action.trim() ? "is-active" : undefined}
onClick={() => setQuickFilter(value)}
>
{t(labelKey)}
</button>
))}
</div>
<div className="filter-inline">
{isAdmin ? (
<input
@ -77,6 +173,7 @@ export function AuditPage() {
onChange={(e) => {
setPage(1);
setAction(e.target.value);
if (e.target.value.trim()) setQuick("");
}}
/>
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching}>
@ -88,6 +185,7 @@ export function AuditPage() {
onClick={() => {
setUsername("");
setAction("");
setQuick("");
setPage(1);
}}
>
@ -109,35 +207,60 @@ export function AuditPage() {
<th>{t("auth.colTime")}</th>
<th>{t("auth.colUser")}</th>
<th>{t("auth.colAction")}</th>
<th>{t("auth.colMethod")}</th>
<th>{t("auth.colPath")}</th>
<th>{t("audit.colSummary")}</th>
<th>{t("auth.colStatus")}</th>
<th>{t("auth.colIp")}</th>
<th>{t("audit.colDetail")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id} title={JSON.stringify(row.detail || {})}>
{items.map((row) => {
const open = expanded === row.id;
const summary = auditSummary(row.action, row.detail || {}, t);
return (
<Fragment key={row.id}>
<tr>
<td className="pt-list-time">
{row.ts ? formatSystemTime(row.ts) : t("common.empty")}
</td>
<td className="pt-list-task-name">{row.actor_username || t("common.empty")}</td>
<td>{row.action}</td>
<td className="pt-list-num">{row.method}</td>
<td
className="pt-list-num"
style={{ maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis" }}
>
{row.path}
</td>
<td className="pt-list-num">{row.action}</td>
<td className="audit-summary-cell">{summary || row.path || t("common.empty")}</td>
<td>
{row.status_code ? (
<span className={`pt-list-status ${statusClass(row.status_code)}`}>
{row.status_code}
</span>
) : (
<span className="muted">—</span>
)}
</td>
<td className="pt-list-num">{row.client_ip || t("common.empty")}</td>
<td>
<button
type="button"
className="linkish"
onClick={() => setExpanded(open ? null : row.id)}
>
{open ? t("audit.hideDetail") : t("audit.showDetail")}
</button>
</td>
</tr>
))}
{open ? (
<tr className="audit-detail-row">
<td colSpan={7}>
<pre className="audit-detail-pre">{JSON.stringify(row.detail || {}, null, 2)}</pre>
{row.method || row.path ? (
<p className="muted audit-detail-meta">
{row.method} {row.path}
</p>
) : null}
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>