diff --git a/server/go/README.md b/server/go/README.md index 6e0f579..d80fc4c 100644 --- a/server/go/README.md +++ b/server/go/README.md @@ -141,7 +141,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。 ./simpleremoter-server ``` -默认监听 TCP 端口 `6543`(被控设备),HTTP 端口 `8080`(浏览器 Web UI)。日志写到 `logs/server.log`。 +默认监听 TCP 端口 `6543`(被控设备),HTTP 端口 `8080`(浏览器 Web UI)。日志写到 `logs/server.log`,安全审计记录另写 `logs/audit.log`。 ### 命令行参数 @@ -170,6 +170,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。 | `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` | | `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` | | `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` | +| `YAMA_AUDIT_LOG` | 安全审计日志路径(JSON lines)。默认 `logs/audit.log`;设为 `off` / `none` / `0` / `-` 关闭独立文件(审计记录仍会写入 `logs/server.log`)。审计记录带 `audit=true` 和稳定的 `event` 名,便于告警与检索。 | `/var/log/yama/audit.log` | | `YAMA_SSO_JWT_SECRET` | **[SSO]** Shared HS256 secret for bridging the parent portal's `auth_token` JWT into a YAMA web session via `/sso`. Must equal the upstream portal's signing key (FMS: `SERVER_PRIVATE_KEY`). **Only `admin` portal users are auto-logged-in**; other roles still need YAMA's own web login. Unset → SSO disabled, `/sso` returns 401. | `` | ```bash @@ -327,6 +328,46 @@ func main() { {"level":"debug","module":"Handler","time":"2025-12-19T13:17:47+01:00","message":"Heartbeat from client 1 (DESKTOP-BI6RGEJ)"} ``` +### 安全审计日志 + +Web 端与设备端的认证、授权、远程控制操作会额外写入**独立**的 `logs/audit.log`(可由 `YAMA_AUDIT_LOG` 改路径或关闭),同时镜像到 `logs/server.log`。每行是一个 JSON 对象,固定带 `audit=true` 与 `event`,其余字段按事件携带 `actor` / `role` / `ip` / `peer_ip` / `conn` / `device_id` / `reason` 等。 + +网页暴露到公网后,优先关注这些事件: + +| 事件 | 含义 | +| ---- | ---- | +| `web.ws.connect` / `web.ws.reject` | WebSocket 建立 / 被拒(Origin 不允许、升级失败) | +| `web.login.success` / `web.login.failure` / `web.login.throttled` | 登录成功、失败(含 `reason`、`user_exists`)、触发限流 | +| `web.salt.unknown_user` | `get_salt` 探测不存在的用户名(用户名枚举信号,每连接记一次) | +| `web.auth.invalid_token` / `web.authz.denied` | 无效 token、权限不足(非 admin 调用管理命令) | +| `web.session.resume` | 浏览器用缓存的 token 在重连后恢复会话 | +| `web.ws.close` | 连接关闭;带 `duration_ms`、`commands` 以及 `invalid_tokens` / `login_failures` / `admin_denied` 等可疑计数 | +| `remote.screen.start` / `.stop` / `.denied` | 远程桌面开始 / 结束(`reason`:disconnect、device_offline、ws_close、replaced)/ 被拒 | +| `remote.terminal.start` / `.stop` / `.denied` | 远程终端会话生命周期 | +| `remote.clipboard.get` / `.set` | 剪贴板读取 / 写入(只记字节数,**不记内容**) | +| `remote.rdp_reset` | 远程 RDP 会话归位操作 | +| `admin.user.create` / `admin.user.delete` / `admin.device.delete` | 管理操作(`target` / `device_id` / `mode`) | +| `admin.audit.query` | 管理员在网页查询审计日志(记录筛选条件与命中数) | +| `device.online` / `device.offline` | 受控设备上线 / 下线 | +| `device.auth.success` / `.failure` / `.throttled` / `.heartbeat.success` | 设备 TCP 授权结果(**只记 passcode 长度,不记 passcode**) | +| `device.connauth.rejected` / `device.subconn.foreign_ip` | 子连接签名失败 / 子连接来源 IP 与主连接不一致 | + +管理员可以在网页里直接查询:设备页工具栏的 **Audit Log** 按钮(`admin` 角色且服务端在 `caps` 中声明 `auditLog` 时显示)打开审计页面,支持按时间范围、事件(精确值或前缀,如 `web.*`)、actor、IP、device_id、关键字筛选以及“仅警告”、分页。页面读取当前 `logs/audit.log`(超大文件只扫描末尾 32 MB,并会提示;已轮转的历史文件仍用下面的 grep/jq 排查),查询动作本身记为 `admin.audit.query`。 + +常用排查命令: +```bash +# 最近的登录失败与被拒操作 +grep -E '"event":"web\.(login\.failure|login\.throttled|authz\.denied|salt\.unknown_user)"' logs/audit.log | tail -50 + +# 按来源 IP 聚合失败次数(找爆破源) +grep '"event":"web.login.failure"' logs/audit.log | jq -r '.ip' | sort | uniq -c | sort -rn | head + +# 谁在什么时候开了远程桌面 +grep '"event":"remote.screen.start"' logs/audit.log | jq -c '{time,actor,role,ip,device_id}' +``` + +注意:鼠标 / 键盘事件不逐条记录(频率太高,会把审计日志刷爆);审计以会话粒度(`remote.screen.*`、`remote.terminal.*`)体现操作。审计记录中的字符串字段来自客户端(用户名、User-Agent、Origin、设备名等),已做控制字符清理与长度截断,避免伪造日志行。 + ## 协议格式 数据包格式与 C++ 版本兼容: diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index 62f3c9a..39fd0e7 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -269,7 +269,12 @@ func (h *MyHandler) rejectConnAuth(ctx *connection.Context, status byte) { if err := h.srv.Send(ctx, ack); err != nil { h.log.Error("ConnAuth reject send failed for conn=%d: %v", ctx.ID, err) } - h.log.Warn("ConnAuth rejected conn=%d ip=%s status=%d", ctx.ID, ctx.GetPeerIP(), status) + h.log.AuditWarn("device.connauth.rejected", map[string]any{ + "conn": ctx.ID, + "ip": ctx.GetPeerIP(), + "status": status, + "device_id": ctx.GetInfo().ClientID, + }) _ = ctx.Close() } @@ -307,8 +312,13 @@ func (h *MyHandler) handleTerminalStart(ctx *connection.Context, isPTY bool) { // closed term_close already), or a hijack attempt from a source IP // other than the device's login conn. Log the latter distinctly. if mc := h.hub.MainConn(devID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() { - h.log.Warn("terminal sub-conn rejected (foreign source IP): device=%s conn=%d src=%s want=%s", - devID, ctx.ID, ctx.GetPeerIP(), mc.GetPeerIP()) + h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{ + "kind": "terminal", + "device_id": devID, + "conn": ctx.ID, + "ip": ctx.GetPeerIP(), + "want_ip": mc.GetPeerIP(), + }) } else { h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v", devID, ctx.ID, isPTY) @@ -350,8 +360,13 @@ func (h *MyHandler) handleBitmapInfo(ctx *connection.Context, data []byte) { // MasterID from a different source IP than the device's login conn. // The latter is a security signal worth surfacing distinctly. if mc := h.hub.MainConn(deviceID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() { - h.log.Warn("screen sub-conn rejected (foreign source IP): device=%s conn=%d src=%s want=%s", - deviceID, ctx.ID, ctx.GetPeerIP(), mc.GetPeerIP()) + h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{ + "kind": "screen", + "device_id": deviceID, + "conn": ctx.ID, + "ip": ctx.GetPeerIP(), + "want_ip": mc.GetPeerIP(), + }) } else { h.log.Warn("orphan screen sub-conn %d for unknown device %s; closing", ctx.ID, deviceID) } @@ -614,7 +629,11 @@ func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) { // definitive answer instead of hanging, while a brute-forcer is capped. ip := ctx.GetPeerIP() if !h.loginLimit.Allow(ip) { - h.log.Warn("TOKEN_AUTH rate-limited: ip=%s conn=%d", ip, ctx.ID) + h.log.AuditWarn("device.auth.throttled", map[string]any{ + "conn": ctx.ID, + "ip": ip, + "reason": "rate_limited", + }) resp := h.auth.BuildResponse(&auth.AuthResult{Valid: false, Message: "尝试过于频繁,请稍后再试"}) if err := h.srv.Send(ctx, resp); err != nil { h.log.Error("Failed to send rate-limit auth response to client %d: %v", ctx.ID, err) @@ -630,12 +649,23 @@ func (h *MyHandler) handleAuth(ctx *connection.Context, data []byte) { authType = "V2" } + // Device auth records are audit events (not just debug logs): a flood of + // failures against one address is how passcode guessing looks. The + // submitted passcode is NEVER logged — only its presence and length. + fields := map[string]any{ + "conn": ctx.ID, + "ip": ip, + "auth_version": authType, + "device_id": info.ClientID, + "computer": info.ComputerName, + "sn": result.SN, + "passcode_len": len(result.Passcode), + "has_passcode": result.Passcode != "", + } if result.Valid { - h.log.Info("Auth %s success: clientID=%s computer=%s ip=%s sn=%s passcode=%s", - authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode) + h.log.Audit("device.auth.success", fields) } else { - h.log.Warn("Auth %s failed: clientID=%s computer=%s ip=%s sn=%s passcode=%s", - authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode) + h.log.AuditWarn("device.auth.failure", fields) } // Build and send response @@ -707,15 +737,22 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) { if !ctx.IsAuthorized.Load() { ctx.IsAuthorized.Store(true) info := ctx.GetInfo() + version := "V1" if authResult.IsV2 { - // V2 authorization - h.log.Info("Heartbeat auth V2 success: clientID=%s computer=%s ip=%s sn=%s passcode=%s", - info.ClientID, info.ComputerName, ctx.GetPeerIP(), authResult.SN, authResult.Passcode) - } else { - // V1 authorization - h.log.Info("Heartbeat auth V1 success: clientID=%s computer=%s ip=%s sn=%s passcode=%s pwdHmac=%d", - info.ClientID, info.ComputerName, ctx.GetPeerIP(), authResult.SN, authResult.Passcode, authResult.PwdHmac) + version = "V2" } + // Same redaction rule as TOKEN_AUTH: never log the passcode + // or the password HMAC, only that they were supplied. + h.log.Audit("device.auth.heartbeat.success", map[string]any{ + "conn": ctx.ID, + "ip": ctx.GetPeerIP(), + "auth_version": version, + "device_id": info.ClientID, + "computer": info.ComputerName, + "sn": authResult.SN, + "passcode_len": len(authResult.Passcode), + "has_pwd_hmac": authResult.PwdHmac != 0, + }) } } } @@ -902,8 +939,30 @@ func main() { logCfg.MaxAge = 30 // 30 days logCfg.Compress = true + // Security audit trail: a separate JSON-lines file so login attempts, + // authz denials, remote-control sessions and admin mutations are not + // buried in debug output and can be retained / shipped independently. + // YAMA_AUDIT_LOG overrides the path; "off"/"none"/"0"/"-" disables the + // separate file (records still reach server.log). + auditFile := strings.TrimSpace(os.Getenv("YAMA_AUDIT_LOG")) + if auditFile == "" { + auditFile = "logs/audit.log" + } + switch strings.ToLower(auditFile) { + case "off", "none", "0", "-": + auditFile = "" + } + logCfg.AuditFile = auditFile + logCfg.AuditMaxSize = 100 + logCfg.AuditMaxBackups = 20 + logCfg.AuditMaxAge = 90 + logCfg.AuditCompress = true + log := logger.New(logCfg) log.Info("====== Copyright (c) 2026 simpleremoter.com. All rights resvered. ======") + if auditFile != "" { + log.Info("Security audit log: %s", auditFile) + } // Track env vars where we fell back to a built-in default. Printed once // at the end of startup so the operator sees what's in effect — vars the @@ -1270,4 +1329,7 @@ func main() { log.Warn("device history flush: %v", err) } log.Info("Server stopped") + // Flush and release the rotated log handles. Without this the last audit + // records can sit in lumberjack's buffered writer on shutdown. + _ = log.Close() } diff --git a/server/go/logger/logger.go b/server/go/logger/logger.go index 69e261c..d4750b7 100644 --- a/server/go/logger/logger.go +++ b/server/go/logger/logger.go @@ -1,10 +1,12 @@ package logger import ( + "fmt" "io" "os" "path/filepath" - "time" + "strings" + "unicode/utf8" "github.com/rs/zerolog" "gopkg.in/natefinch/lumberjack.v2" @@ -24,6 +26,13 @@ const ( // Logger wraps zerolog.Logger type Logger struct { zl zerolog.Logger + // audit is a second, file-only logger used for security audit records. + // It writes JSON lines to AuditFile so audit data is not buried in + // debug/operational output and can be retained independently. + audit zerolog.Logger + auditEnabled bool + auditPath string // "" when the separate audit file is disabled + closers []io.Closer } // Config holds logger configuration @@ -42,6 +51,19 @@ type Config struct { MaxAge int // Compress enables gzip compression for rotated files Compress bool + + // AuditFile is the dedicated security-audit log path ("" disables the + // separate audit file; audit records still go to the main log). Audit + // records are JSON lines with audit=true, event=, and contextual + // fields such as actor/role/ip/device_id. + AuditFile string + // AuditMaxSize / AuditMaxBackups / AuditMaxAge tune audit rotation. + // Zero values pick 100 MB / 20 files / 90 days. + AuditMaxSize int + AuditMaxBackups int + AuditMaxAge int + // AuditCompress enables gzip compression for rotated audit files. + AuditCompress bool } // DefaultConfig returns default configuration @@ -60,6 +82,7 @@ func DefaultConfig() Config { // New creates a new logger with config func New(cfg Config) *Logger { var writers []io.Writer + var closers []io.Closer // Console output with color if cfg.Console { @@ -87,6 +110,7 @@ func New(cfg Config) *Logger { LocalTime: true, } writers = append(writers, fileWriter) + closers = append(closers, fileWriter) } // Combine writers @@ -106,14 +130,78 @@ func New(cfg Config) *Logger { Timestamp(). Logger() - return &Logger{zl: zl} + l := &Logger{zl: zl, closers: closers} + + if cfg.AuditFile != "" { + dir := filepath.Dir(cfg.AuditFile) + if dir != "" && dir != "." { + _ = os.MkdirAll(dir, 0755) + } + maxSize := cfg.AuditMaxSize + if maxSize <= 0 { + maxSize = 100 + } + maxBackups := cfg.AuditMaxBackups + if maxBackups <= 0 { + maxBackups = 20 + } + maxAge := cfg.AuditMaxAge + if maxAge <= 0 { + maxAge = 90 + } + auditWriter := &lumberjack.Logger{ + Filename: cfg.AuditFile, + MaxSize: maxSize, + MaxBackups: maxBackups, + MaxAge: maxAge, + Compress: cfg.AuditCompress, + LocalTime: true, + } + l.audit = zerolog.New(auditWriter). + Level(zerolog.InfoLevel). + With(). + Timestamp(). + Logger() + l.auditEnabled = true + l.auditPath = cfg.AuditFile + l.closers = append(l.closers, auditWriter) + } + + return l +} + +// Close releases any file handles owned by the logger. Safe to call multiple +// times. Loggers derived with WithPrefix share the base logger's files but do +// not own them, so call Close on the logger returned by New. +func (l *Logger) Close() error { + if l == nil { + return nil + } + for _, c := range l.closers { + _ = c.Close() + } + l.closers = nil + return nil } // WithPrefix returns a new logger with a prefix field func (l *Logger) WithPrefix(prefix string) *Logger { - return &Logger{ - zl: l.zl.With().Str("module", prefix).Logger(), + n := &Logger{zl: l.zl.With().Str("module", prefix).Logger(), auditPath: l.auditPath} + if l.auditEnabled { + n.audit = l.audit.With().Str("module", prefix).Logger() + n.auditEnabled = true } + return n +} + +// AuditPath returns the path of the dedicated audit log file, or "" when the +// separate audit file is disabled (audit records then only reach the main log). +// Derived loggers (WithPrefix) report the same path. +func (l *Logger) AuditPath() string { + if l == nil { + return "" + } + return l.auditPath } // Debug logs a debug message @@ -151,21 +239,102 @@ func (l *Logger) GetLevel() Level { return l.zl.GetLevel() } -// ClientEvent logs client online/offline events +// ClientEvent logs client online/offline events as audit records. func (l *Logger) ClientEvent(event string, clientID uint64, ip string, extra ...string) { - e := l.zl.Info(). - Str("event", event). - Uint64("client_id", clientID). - Str("ip", ip). - Time("time", time.Now()) - - if len(extra) >= 2 { - for i := 0; i+1 < len(extra); i += 2 { - e = e.Str(extra[i], extra[i+1]) - } + // clientID is the TCP connection id; the device's stable YAMA id arrives + // as the "clientID" extra and is normalized to device_id so every audit + // record uses the same field names (conn / device_id). + fields := map[string]any{ + "conn": clientID, + "ip": ip, } + for i := 0; i+1 < len(extra); i += 2 { + key := extra[i] + if key == "clientID" { + key = "device_id" + } + fields[key] = extra[i+1] + } + l.Audit("device."+event, fields) +} - e.Msg("") +// Audit writes one security-audit record at info level. +func (l *Logger) Audit(event string, fields map[string]any) { + l.AuditAt(LevelInfo, event, fields) +} + +// AuditWarn writes one security-audit record at warn level. Use it for +// rejected / failed security-relevant operations. +func (l *Logger) AuditWarn(event string, fields map[string]any) { + l.AuditAt(LevelWarn, event, fields) +} + +// AuditAt writes a security-audit record to the dedicated audit file (when +// configured) and mirrors it to the main logger so operators tailing +// server.log still see security events. event is a stable dotted name such +// as "web.login.failure"; fields carry actor/role/ip/device_id/reason. +// +// Untrusted string values (usernames, User-Agent, Origin, device strings) +// are sanitized: control characters are replaced so they cannot forge log +// lines, and long values are truncated. +func (l *Logger) AuditAt(level Level, event string, fields map[string]any) { + if l == nil { + return + } + clean := make(map[string]any, len(fields)+2) + for k, v := range fields { + clean[k] = sanitizeAuditValue(v) + } + clean["audit"] = true + clean["event"] = event + + if l.auditEnabled { + l.audit.WithLevel(level).Fields(clean).Msg("audit") + } + l.zl.WithLevel(level).Fields(clean).Msg("audit") +} + +// maxAuditValueLen bounds untrusted log values (runes). A 1 MB WebSocket +// frame could otherwise be reflected verbatim into every audit line. +const maxAuditValueLen = 512 + +// maxAuditValueBytes is the pre-sanitization byte cap. It keeps a single +// hostile value from allocating an unbounded rune slice before truncation. +const maxAuditValueBytes = 4096 + +func sanitizeAuditValue(v any) any { + switch x := v.(type) { + case string: + return sanitizeAuditString(x) + case []byte: + return sanitizeAuditString(string(x)) + case error: + return sanitizeAuditString(x.Error()) + case fmt.Stringer: + return sanitizeAuditString(x.String()) + default: + // Numeric/bool values are code-controlled and log-safe. + return v + } +} + +func sanitizeAuditString(s string) string { + if len(s) > maxAuditValueBytes { + s = s[:maxAuditValueBytes] + } + s = strings.Map(func(r rune) rune { + // C0 controls, DEL, and Unicode line/paragraph separators can be + // used to forge or visually split log lines in console output. + if r < 0x20 || r == 0x7f || r == 0x2028 || r == 0x2029 { + return ' ' + } + return r + }, s) + if utf8.RuneCountInString(s) > maxAuditValueLen { + r := []rune(s) + s = string(r[:maxAuditValueLen]) + } + return s } // default global logger diff --git a/server/go/logger/logger_test.go b/server/go/logger/logger_test.go new file mode 100644 index 0000000..ebb55a6 --- /dev/null +++ b/server/go/logger/logger_test.go @@ -0,0 +1,156 @@ +package logger + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// readAuditLines parses logs/audit.log-style JSON lines. Callers assert on +// individual records rather than raw text so field ordering is irrelevant. +func readAuditLines(t *testing.T, path string) []map[string]any { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatalf("open audit log: %v", err) + } + defer f.Close() + + var out []map[string]any + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("audit line is not JSON: %q: %v", line, err) + } + out = append(out, m) + } + if err := sc.Err(); err != nil { + t.Fatalf("scan audit log: %v", err) + } + return out +} + +func TestAuditWritesDedicatedFileAndMirrorsMainLog(t *testing.T) { + dir := t.TempDir() + auditPath := filepath.Join(dir, "audit.log") + mainPath := filepath.Join(dir, "server.log") + + l := New(Config{ + Level: LevelInfo, + Console: false, + File: mainPath, + AuditFile: auditPath, + }) + t.Cleanup(func() { _ = l.Close() }) + + l.Audit("web.login.success", map[string]any{ + "actor": "admin", + "role": "admin", + "ip": "203.0.113.9", + }) + + lines := readAuditLines(t, auditPath) + if len(lines) != 1 { + t.Fatalf("audit lines = %d, want 1", len(lines)) + } + rec := lines[0] + if rec["audit"] != true { + t.Errorf("audit = %v, want true", rec["audit"]) + } + if rec["event"] != "web.login.success" { + t.Errorf("event = %v", rec["event"]) + } + if rec["actor"] != "admin" || rec["role"] != "admin" || rec["ip"] != "203.0.113.9" { + t.Errorf("fields = %+v", rec) + } + if _, ok := rec["time"]; !ok { + t.Error("record has no timestamp") + } + + // The same record must reach server.log so operators tailing one file + // still see security events. + mainLines := readAuditLines(t, mainPath) + found := false + for _, m := range mainLines { + if m["event"] == "web.login.success" && m["audit"] == true { + found = true + } + } + if !found { + t.Errorf("audit record not mirrored to main log: %+v", mainLines) + } +} + +func TestAuditSanitizesUntrustedValues(t *testing.T) { + dir := t.TempDir() + auditPath := filepath.Join(dir, "audit.log") + l := New(Config{Console: false, File: filepath.Join(dir, "server.log"), AuditFile: auditPath}) + t.Cleanup(func() { _ = l.Close() }) + + l.Audit("web.login.failure", map[string]any{ + // Newlines must not be able to forge extra log lines. + "username": "evil\n{\"audit\":true,\"event\":\"web.login.success\"}", + "ua": strings.Repeat("A", maxAuditValueLen+50), + }) + + lines := readAuditLines(t, auditPath) + if len(lines) != 1 { + t.Fatalf("audit lines = %d, want 1 (newline forged a record?)", len(lines)) + } + username, _ := lines[0]["username"].(string) + if strings.ContainsAny(username, "\n\r") { + t.Errorf("username still contains a newline: %q", username) + } + if !strings.Contains(username, `{"audit":true`) { + t.Errorf("username content lost: %q", username) + } + ua, _ := lines[0]["ua"].(string) + if got := len([]rune(ua)); got != maxAuditValueLen { + t.Errorf("ua rune length = %d, want %d", got, maxAuditValueLen) + } +} + +func TestAuditDisabledFileStillLogsToMain(t *testing.T) { + dir := t.TempDir() + mainPath := filepath.Join(dir, "server.log") + l := New(Config{Console: false, File: mainPath}) // no AuditFile + t.Cleanup(func() { _ = l.Close() }) + + l.AuditWarn("web.authz.denied", map[string]any{"role": "viewer"}) + + lines := readAuditLines(t, mainPath) + if len(lines) != 1 || lines[0]["event"] != "web.authz.denied" { + t.Fatalf("main log lines = %+v", lines) + } + if _, err := os.Stat(filepath.Join(dir, "audit.log")); !os.IsNotExist(err) { + t.Errorf("audit.log unexpectedly created: %v", err) + } +} + +func TestClientEventEmitsDeviceAudit(t *testing.T) { + dir := t.TempDir() + auditPath := filepath.Join(dir, "audit.log") + l := New(Config{Console: false, File: filepath.Join(dir, "server.log"), AuditFile: auditPath}) + t.Cleanup(func() { _ = l.Close() }) + + l.ClientEvent("online", 42, "198.51.100.7", "clientID", "abc", "computer", "PC-01") + + lines := readAuditLines(t, auditPath) + if len(lines) != 1 { + t.Fatalf("audit lines = %d, want 1", len(lines)) + } + if lines[0]["event"] != "device.online" { + t.Errorf("event = %v, want device.online", lines[0]["event"]) + } + if lines[0]["device_id"] != "abc" { + t.Errorf("device_id = %v", lines[0]["device_id"]) + } +} diff --git a/server/go/web/audit.go b/server/go/web/audit.go new file mode 100644 index 0000000..4f73c1b --- /dev/null +++ b/server/go/web/audit.go @@ -0,0 +1,171 @@ +package web + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "time" +) + +// Stable audit event names. Keep these stable: operators build alerts and +// dashboards on them. Every record also carries audit=true. +const ( + auditWSConnect = "web.ws.connect" + auditWSReject = "web.ws.reject" + auditWSClose = "web.ws.close" + auditLoginOK = "web.login.success" + auditLoginFail = "web.login.failure" + auditLoginThrot = "web.login.throttled" + auditSession = "web.session.resume" + auditBadToken = "web.auth.invalid_token" + auditDenied = "web.authz.denied" + auditUnknownCmd = "web.command.unknown" + auditSaltProbe = "web.salt.unknown_user" + + auditScreenStart = "remote.screen.start" + auditScreenStop = "remote.screen.stop" + auditScreenDenied = "remote.screen.denied" + auditTermStart = "remote.terminal.start" + auditTermStop = "remote.terminal.stop" + auditTermDenied = "remote.terminal.denied" + auditClipSet = "remote.clipboard.set" + auditClipGet = "remote.clipboard.get" + auditRdpReset = "remote.rdp_reset" + + auditUserCreate = "admin.user.create" + auditUserDelete = "admin.user.delete" + auditDeviceAdmin = "admin.device.delete" + auditQuery = "admin.audit.query" +) + +// audit emits one audit record for a WebSocket client. Identity fields are +// read under h.mu because requireAuth/handleLogin install them there and +// broadcast goroutines may observe the same client concurrently. +// +// Callers MUST NOT hold h.mu when calling this method. +func (h *wsHub) audit(c *wsClient, warn bool, event string, fields map[string]any) { + if fields == nil { + fields = make(map[string]any) + } + if c != nil { + h.mu.RLock() + connID := c.id + addr := c.addr + peer := c.peerAddr + user := c.user + role := c.role + h.mu.RUnlock() + + if _, ok := fields["conn"]; !ok && connID != "" { + fields["conn"] = connID + } + if _, ok := fields["ip"]; !ok && addr != "" { + fields["ip"] = addr + } + if _, ok := fields["peer_ip"]; !ok && peer != "" && peer != addr { + fields["peer_ip"] = peer + } + if _, ok := fields["actor"]; !ok && user != "" { + fields["actor"] = user + } + if _, ok := fields["role"]; !ok && role != "" { + fields["role"] = role + } + } + if warn { + h.log.AuditWarn(event, fields) + } else { + h.log.Audit(event, fields) + } +} + +// auditClose records the end of a browser WebSocket. Anonymous scanners that +// connect and do nothing produce only the connect event; authenticated +// sessions, failed-login probes, and command traffic always produce a close +// record with counters that help spot brute-force / probing behavior. +func (h *wsHub) auditClose(c *wsClient, screens int) { + suspicious := c.statInvalidToken > 0 || + c.statLoginFailures > 0 || + c.statAdminDenied > 0 || + c.statUnknownSalt > 0 || + c.statUnknownCmd > 0 + if c.user == "" && c.statCommands == 0 && !suspicious { + return + } + + fields := map[string]any{ + "duration_ms": time.Since(c.connectedAt).Milliseconds(), + "commands": c.statCommands, + } + if screens > 0 { + fields["screens"] = screens + } + if c.statInvalidToken > 0 { + fields["invalid_tokens"] = c.statInvalidToken + } + if c.statLoginFailures > 0 { + fields["login_failures"] = c.statLoginFailures + } + if c.statAdminDenied > 0 { + fields["admin_denied"] = c.statAdminDenied + } + if c.statUnknownSalt > 0 { + fields["unknown_salt"] = c.statUnknownSalt + } + if c.statUnknownCmd > 0 { + fields["unknown_commands"] = c.statUnknownCmd + } + h.audit(c, suspicious, auditWSClose, fields) +} + +// requestFields builds the audit context available before a WebSocket client +// exists (upgrade rejected) or for plain HTTP endpoints (SSO). +func requestFields(r *http.Request, trustForwardedFor bool) map[string]any { + fields := make(map[string]any) + if r == nil { + return fields + } + ip := clientIP(r, trustForwardedFor) + if ip != "" { + fields["ip"] = ip + } + if peer := stripPort(r.RemoteAddr); peer != "" && peer != ip { + fields["peer_ip"] = peer + } + if origin := r.Header.Get("Origin"); origin != "" { + fields["origin"] = origin + } + if ua := r.UserAgent(); ua != "" { + fields["ua"] = ua + } + return fields +} + +// wsRequestFields returns request metadata worth attaching to login events. +func (c *wsClient) wsRequestFields() map[string]any { + fields := make(map[string]any) + if c.origin != "" { + fields["origin"] = c.origin + } + if c.userAgent != "" { + fields["ua"] = c.userAgent + } + return fields +} + +func durationMs(start time.Time) int64 { + if start.IsZero() { + return 0 + } + return time.Since(start).Milliseconds() +} + +// newConnID returns a short random identifier used to correlate all audit +// records belonging to one browser WebSocket connection. +func newConnID() string { + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + return "conn" + } + return hex.EncodeToString(b[:]) +} diff --git a/server/go/web/audit_query.go b/server/go/web/audit_query.go new file mode 100644 index 0000000..6e10c16 --- /dev/null +++ b/server/go/web/audit_query.go @@ -0,0 +1,328 @@ +package web + +import ( + "bufio" + "encoding/json" + "os" + "strings" + "time" +) + +// Audit-log query support for the admin-only "Audit Log" page. +// +// The server reads its own JSON-lines audit file (logs/audit.log) back to the +// browser. Records are already redacted at write time (see logger.AuditAt), so +// returning raw lines is safe as long as we never widen what is written. +// +// Only the CURRENT audit file is searched. Rotated files (audit-*.log[.gz]) +// are deliberately out of scope: they need gzip support and a separate index, +// and the UI is meant for recent-window triage ("what happened just now"). +const ( + auditQueryDefaultLimit = 100 + auditQueryMaxLimit = 500 + auditQueryMaxOffset = 10000 + + // auditQueryScanBudget bounds how much of the audit file a single query + // reads. The tail is read when the file is larger, so the newest records + // (what operators actually triage) are always covered and a multi-GB log + // cannot stall the WS goroutine. + auditQueryScanBudget = 32 << 20 // 32 MB +) + +// auditQueryRequest is the JSON body of the `query_audit` command. +type auditQueryRequest struct { + From string `json:"from"` // RFC3339 ("2026-10-18T10:00:00Z"), optional + To string `json:"to"` // RFC3339, optional + Event string `json:"event"` // exact ("web.login.failure"), prefix ("web." / "web.*"), or "" + Actor string `json:"actor"` // exact, case-insensitive + IP string `json:"ip"` // exact + DeviceID string `json:"device_id"` + Keyword string `json:"keyword"` // case-insensitive substring of the raw record + WarnOnly bool `json:"warn_only"` // only level=warn records + Limit int `json:"limit"` + Offset int `json:"offset"` // skip the newest N matching records +} + +// auditQueryResult is the internal result of scanning the audit file. +type auditQueryResult struct { + Events []map[string]any + Total int // matching records in the scanned window + FileExists bool + FileSize int64 + ScannedBytes int64 + Truncated bool // the file was larger than auditQueryScanBudget +} + +// handleQueryAudit implements the admin-only audit query command. The reply is +// `{cmd:"audit_result", ok, events, total, ...}`; events are newest-first. +func (h *wsHub) handleQueryAudit(c *wsClient, raw []byte) { + const replyCmd = "audit_result" + if !h.requireAdmin(c, raw, replyCmd) { + return + } + var in auditQueryRequest + if err := json.Unmarshal(raw, &in); err != nil { + c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid request"})) + return + } + in.Limit = clampAuditLimit(in.Limit) + in.Offset = clampAuditOffset(in.Offset) + + path := h.log.AuditPath() + if path == "" { + h.audit(c, true, auditQuery, map[string]any{ + "ok": false, + "reason": "audit_log_disabled", + }) + c.queue(mustJSON(map[string]any{ + "cmd": replyCmd, "ok": false, + "msg": "Audit log file is disabled on this server (YAMA_AUDIT_LOG=off)", + })) + return + } + + res, err := queryAuditLog(path, in) + if err != nil { + h.audit(c, true, auditQuery, map[string]any{ + "ok": false, + "reason": "read_failed", + "error": err.Error(), + }) + c.queue(mustJSON(map[string]any{ + "cmd": replyCmd, "ok": false, + "msg": "Failed to read audit log: " + err.Error(), + })) + return + } + + // Record the query itself: an audit viewer is privileged, and reading the + // trail is itself an auditable action. Filter terms are included so an + // investigator can see what was searched for. + h.audit(c, false, auditQuery, map[string]any{ + "ok": true, + "event_filter": in.Event, + "actor_filter": in.Actor, + "ip_filter": in.IP, + "device_filter": in.DeviceID, + "keyword": in.Keyword, + "warn_only": in.WarnOnly, + "matches": res.Total, + "returned": len(res.Events), + "offset": in.Offset, + }) + + c.queue(mustJSON(map[string]any{ + "cmd": replyCmd, + "ok": true, + "events": res.Events, + "total": res.Total, + "offset": in.Offset, + "limit": in.Limit, + "file": path, + "file_exists": res.FileExists, + "file_size": res.FileSize, + "scanned_bytes": res.ScannedBytes, + "truncated": res.Truncated, + })) +} + +func clampAuditLimit(n int) int { + if n <= 0 { + return auditQueryDefaultLimit + } + if n > auditQueryMaxLimit { + return auditQueryMaxLimit + } + return n +} + +func clampAuditOffset(n int) int { + if n < 0 { + return 0 + } + if n > auditQueryMaxOffset { + return auditQueryMaxOffset + } + return n +} + +// queryAuditLog scans path for audit records matching q. The returned events +// are the newest limit records after skipping offset, in newest-first order. +func queryAuditLog(path string, q auditQueryRequest) (auditQueryResult, error) { + res := auditQueryResult{Events: []map[string]any{}} + + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + // No audit record has been written yet (lumberjack creates the file + // lazily). An empty result is correct, not an error. + return res, nil + } + return res, err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return res, err + } + res.FileExists = true + res.FileSize = info.Size() + + // For very large files, scan only the tail; the newest matches live there. + start := int64(0) + if info.Size() > auditQueryScanBudget { + start = info.Size() - auditQueryScanBudget + if _, err := f.Seek(start, 0); err != nil { + return res, err + } + res.Truncated = true + } + + from, hasFrom := parseAuditTime(q.From) + to, hasTo := parseAuditTime(q.To) + + keep := q.Offset + q.Limit + ring := make([]map[string]any, keep) + filled := 0 + + sc := bufio.NewScanner(f) + // Sanitized values are capped at 4096 bytes each; a record with many + // fields stays well under 1 MB. Allow headroom rather than aborting the + // whole query on one long line. + sc.Buffer(make([]byte, 64*1024), 4*1024*1024) + + first := true + for sc.Scan() { + line := sc.Text() + if start > 0 && first { + // Seeking into the middle of the file lands mid-line. Drop that + // partial record. + first = false + continue + } + first = false + res.ScannedBytes += int64(len(line)) + 1 + + line = strings.TrimSpace(line) + if line == "" { + continue + } + var rec map[string]any + if err := json.Unmarshal([]byte(line), &rec); err != nil { + continue // tolerate a torn line instead of failing the page + } + if _, ok := rec["event"].(string); !ok { + continue + } + // Only audit records belong on the page. The dedicated file contains + // nothing else, but a hand-edited or mixed file must not surface + // ordinary operational lines here. + if rec["audit"] != true { + continue + } + if !auditRecordMatches(rec, line, q, from, hasFrom, to, hasTo) { + continue + } + // Ring buffer of the last `keep` matches (file order is chronological). + ring[res.Total%keep] = rec + if filled < keep { + filled++ + } + res.Total++ + } + if err := sc.Err(); err != nil { + return res, err + } + + // Newest first, skipping Offset. The match with 0-based index i lives at + // ring[i%keep] as long as i >= Total-filled. + for i := res.Total - 1 - q.Offset; i >= 0 && i >= res.Total-filled; i-- { + res.Events = append(res.Events, ring[i%keep]) + if len(res.Events) >= q.Limit { + break + } + } + return res, nil +} + +// auditRecordMatches applies the non-time and time filters to one record. +// line is the raw JSON line, used for the keyword substring search. +func auditRecordMatches(rec map[string]any, line string, q auditQueryRequest, from time.Time, hasFrom bool, to time.Time, hasTo bool) bool { + if q.Event != "" && !auditEventFilterMatches(q.Event, auditStringField(rec, "event")) { + return false + } + if q.Actor != "" && !strings.EqualFold(auditStringField(rec, "actor"), q.Actor) { + return false + } + if q.IP != "" && auditStringField(rec, "ip") != q.IP { + return false + } + if q.DeviceID != "" && auditStringField(rec, "device_id") != q.DeviceID { + return false + } + if q.WarnOnly && auditStringField(rec, "level") != "warn" { + return false + } + if q.Keyword != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(q.Keyword)) { + return false + } + if hasFrom || hasTo { + t, ok := parseAuditTime(auditStringField(rec, "time")) + if !ok { + return false // cannot prove it is in range + } + if hasFrom && t.Before(from) { + return false + } + if hasTo && t.After(to) { + return false + } + } + return true +} + +// auditEventFilterMatches supports three forms so the UI stays simple: +// +// "web.login.failure" exact +// "web.*" prefix (trailing "*") +// "web." prefix (trailing ".") +func auditEventFilterMatches(filter, event string) bool { + switch { + case strings.HasSuffix(filter, "*"): + return strings.HasPrefix(event, strings.TrimSuffix(filter, "*")) + case strings.HasSuffix(filter, "."): + return strings.HasPrefix(event, filter) + default: + return event == filter + } +} + +func auditStringField(rec map[string]any, key string) string { + s, _ := rec[key].(string) + return s +} + +// parseAuditTime accepts the formats operators and browsers realistically send. +// Layouts without an explicit zone are parsed as UTC; the frontend always sends +// Date.toISOString(), so the RFC3339 path is what production uses. +func parseAuditTime(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, false + } + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02T15:04", + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} diff --git a/server/go/web/audit_query_test.go b/server/go/web/audit_query_test.go new file mode 100644 index 0000000..9425dca --- /dev/null +++ b/server/go/web/audit_query_test.go @@ -0,0 +1,294 @@ +package web + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" + "github.com/yuanyuanxiang/SimpleRemoter/server/go/logger" + "github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth" +) + +// newAuditTestHubWithoutAuditFile builds a hub whose logger has no dedicated +// audit file, mirroring a server started with YAMA_AUDIT_LOG=off. +func newAuditTestHubWithoutAuditFile(t *testing.T, dir string) (*wsHub, string) { + t.Helper() + lg := logger.New(logger.Config{ + Level: logger.LevelInfo, + Console: false, + File: filepath.Join(dir, "server.log"), + }) + t.Cleanup(func() { _ = lg.Close() }) + wh := newWSHub(wsauth.New(), hub.New(), nil, lg) + t.Cleanup(wh.stop) + return wh, "" +} + +func readQueuedJSON(t *testing.T, c *wsClient) map[string]any { + t.Helper() + select { + case m := <-c.send: + var v map[string]any + if err := json.Unmarshal(m.data, &v); err != nil { + t.Fatalf("queued reply is not JSON: %q: %v", m.data, err) + } + return v + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for a queued reply") + return nil + } +} + +// writeAuditFixture writes JSON-lines audit records (oldest first) and returns +// the file path. Times are UTC RFC3339 so the tests are timezone-independent. +func writeAuditFixture(t *testing.T, records []map[string]any) string { + t.Helper() + path := filepath.Join(t.TempDir(), "audit.log") + var b strings.Builder + for _, rec := range records { + line, err := json.Marshal(rec) + if err != nil { + t.Fatalf("marshal fixture: %v", err) + } + b.Write(line) + b.WriteByte('\n') + } + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + return path +} + +func auditFixtureRecords() []map[string]any { + at := func(hhmm string) string { + tm, err := time.Parse("2006-01-02T15:04", "2026-10-18T"+hhmm) + if err != nil { + panic(err) + } + return tm.Format(time.RFC3339) + } + return []map[string]any{ + {"level": "info", "time": at("10:00"), "audit": true, "event": "web.login.success", "actor": "admin", "ip": "203.0.113.1"}, + {"level": "warn", "time": at("10:01"), "audit": true, "event": "web.login.failure", "actor": "alice", "ip": "203.0.113.2", "reason": "invalid_credentials"}, + {"level": "info", "time": at("10:02"), "audit": true, "event": "remote.screen.start", "actor": "admin", "role": "admin", "ip": "203.0.113.1", "device_id": "dev1"}, + {"level": "info", "time": at("10:03"), "audit": true, "event": "admin.user.delete", "actor": "admin", "ip": "203.0.113.1", "target": "bob"}, + } +} + +func runAuditQuery(t *testing.T, path string, q auditQueryRequest) auditQueryResult { + t.Helper() + q.Limit = clampAuditLimit(q.Limit) + q.Offset = clampAuditOffset(q.Offset) + res, err := queryAuditLog(path, q) + if err != nil { + t.Fatalf("queryAuditLog: %v", err) + } + return res +} + +func eventNames(res auditQueryResult) []string { + out := make([]string, 0, len(res.Events)) + for _, e := range res.Events { + out = append(out, auditStringField(e, "event")) + } + return out +} + +func TestAuditQueryNoFilterNewestFirst(t *testing.T) { + path := writeAuditFixture(t, auditFixtureRecords()) + res := runAuditQuery(t, path, auditQueryRequest{Limit: 100}) + if res.Total != 4 || len(res.Events) != 4 { + t.Fatalf("total=%d events=%d, want 4/4", res.Total, len(res.Events)) + } + got := eventNames(res) + want := []string{"admin.user.delete", "remote.screen.start", "web.login.failure", "web.login.success"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("events = %v, want %v", got, want) + } +} + +func TestAuditQueryEventFilters(t *testing.T) { + path := writeAuditFixture(t, auditFixtureRecords()) + cases := []struct { + filter string + want []string + }{ + {"web.", []string{"web.login.failure", "web.login.success"}}, + {"web.*", []string{"web.login.failure", "web.login.success"}}, + {"web.login.failure", []string{"web.login.failure"}}, + {"remote.*", []string{"remote.screen.start"}}, + } + for _, tc := range cases { + res := runAuditQuery(t, path, auditQueryRequest{Event: tc.filter, Limit: 100}) + got := eventNames(res) + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Errorf("filter %q → %v, want %v", tc.filter, got, tc.want) + } + } +} + +func TestAuditQueryFieldFilters(t *testing.T) { + path := writeAuditFixture(t, auditFixtureRecords()) + from, _ := time.Parse(time.RFC3339, "2026-10-18T10:01:00Z") + to, _ := time.Parse(time.RFC3339, "2026-10-18T10:02:00Z") + + cases := []struct { + name string + q auditQueryRequest + want []string + }{ + {"actor case-insensitive", auditQueryRequest{Actor: "ALICE", Limit: 100}, []string{"web.login.failure"}}, + {"ip", auditQueryRequest{IP: "203.0.113.2", Limit: 100}, []string{"web.login.failure"}}, + {"device", auditQueryRequest{DeviceID: "dev1", Limit: 100}, []string{"remote.screen.start"}}, + {"warn only", auditQueryRequest{WarnOnly: true, Limit: 100}, []string{"web.login.failure"}}, + {"keyword target", auditQueryRequest{Keyword: "BOB", Limit: 100}, []string{"admin.user.delete"}}, + {"time range", auditQueryRequest{ + From: from.Format(time.RFC3339), To: to.Format(time.RFC3339), Limit: 100, + }, []string{"remote.screen.start", "web.login.failure"}}, + } + for _, tc := range cases { + res := runAuditQuery(t, path, tc.q) + got := eventNames(res) + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Errorf("%s → %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestAuditQueryOffsetAndLimit(t *testing.T) { + path := writeAuditFixture(t, auditFixtureRecords()) + res := runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 1}) + if res.Total != 4 { + t.Fatalf("total = %d, want 4", res.Total) + } + got := eventNames(res) + want := []string{"remote.screen.start", "web.login.failure"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("events = %v, want %v", got, want) + } + + // Offsetting past the end yields no events but keeps the total. + res = runAuditQuery(t, path, auditQueryRequest{Limit: 2, Offset: 4}) + if res.Total != 4 || len(res.Events) != 0 { + t.Fatalf("offset past end: total=%d events=%v", res.Total, eventNames(res)) + } +} + +func TestAuditQuerySkipsNonAuditAndTornLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.log") + content := `{"event":"device.online","actor":"x"} +not json at all +{"level":"info","time":"2026-10-18T10:00:00Z","audit":true,"event":"web.login.success","actor":"admin"}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + res := runAuditQuery(t, path, auditQueryRequest{Limit: 100}) + if res.Total != 1 || len(res.Events) != 1 { + t.Fatalf("total=%d events=%v, want the single valid record", res.Total, eventNames(res)) + } +} + +func TestAuditQueryMissingFile(t *testing.T) { + res := runAuditQuery(t, filepath.Join(t.TempDir(), "does-not-exist.log"), auditQueryRequest{Limit: 10}) + if res.FileExists { + t.Error("FileExists = true for a missing file") + } + if res.Total != 0 || len(res.Events) != 0 { + t.Errorf("total=%d events=%v, want empty", res.Total, eventNames(res)) + } + if res.Events == nil { + t.Error("Events is nil; JSON would encode null instead of []") + } +} + +func TestAuditQueryClampsPaging(t *testing.T) { + if got := clampAuditLimit(0); got != auditQueryDefaultLimit { + t.Errorf("limit(0) = %d, want default %d", got, auditQueryDefaultLimit) + } + if got := clampAuditLimit(-3); got != auditQueryDefaultLimit { + t.Errorf("limit(-3) = %d, want default %d", got, auditQueryDefaultLimit) + } + if got := clampAuditLimit(9999); got != auditQueryMaxLimit { + t.Errorf("limit(9999) = %d, want max %d", got, auditQueryMaxLimit) + } + if got := clampAuditLimit(50); got != 50 { + t.Errorf("limit(50) = %d, want 50", got) + } + if got := clampAuditOffset(-5); got != 0 { + t.Errorf("offset(-5) = %d, want 0", got) + } + if got := clampAuditOffset(1 << 30); got != auditQueryMaxOffset { + t.Errorf("offset(huge) = %d, want max %d", got, auditQueryMaxOffset) + } +} + +func TestQueryAuditHandlerAdminOnly(t *testing.T) { + wh, _, _ := auditTestHub(t) + viewer, tok := auditTestClient(wh, "viewer") + raw, _ := json.Marshal(map[string]any{"token": tok}) + wh.handleQueryAudit(viewer, raw) + + msg := readQueuedJSON(t, viewer) + if msg["cmd"] != "audit_result" || msg["ok"] != false { + t.Fatalf("viewer reply = %+v", msg) + } + if _, ok := msg["events"]; ok { + t.Error("denied viewer got events") + } +} + +func TestQueryAuditHandlerReturnsEvents(t *testing.T) { + wh, _, _ := auditTestHub(t) + // Two records written directly through the same logger the hub queries. + wh.log.Audit("web.login.success", map[string]any{"actor": "admin", "ip": "203.0.113.1"}) + wh.log.Audit("remote.screen.start", map[string]any{"actor": "admin", "device_id": "dev1"}) + + admin, tok := auditTestClient(wh, "admin") + raw, _ := json.Marshal(map[string]any{"token": tok, "limit": 10}) + wh.handleQueryAudit(admin, raw) + + msg := readQueuedJSON(t, admin) + if msg["cmd"] != "audit_result" || msg["ok"] != true { + t.Fatalf("reply = %+v", msg) + } + events, ok := msg["events"].([]any) + if !ok || len(events) < 2 { + t.Fatalf("events = %#v, want at least the two written records", msg["events"]) + } + // requireAuth writes a web.session.resume record for this same request, so + // that one legitimately appears newer. Assert the two written records are + // present and still newest-first relative to each other. + var names []string + for _, e := range events { + if m, ok := e.(map[string]any); ok { + names = append(names, auditStringField(m, "event")) + } + } + joined := strings.Join(names, ",") + if !strings.Contains(joined, "remote.screen.start") || !strings.Contains(joined, "web.login.success") { + t.Fatalf("events = %v, want both written records", names) + } + if strings.Index(joined, "remote.screen.start") > strings.Index(joined, "web.login.success") { + t.Fatalf("events = %v, want newest-first order", names) + } + if msg["truncated"] != false || msg["file_exists"] != true { + t.Errorf("meta = %+v", msg) + } +} + +func TestQueryAuditHandlerDisabledFile(t *testing.T) { + dir := t.TempDir() + wh, _ := newAuditTestHubWithoutAuditFile(t, dir) + admin, tok := auditTestClient(wh, "admin") + raw, _ := json.Marshal(map[string]any{"token": tok}) + wh.handleQueryAudit(admin, raw) + + msg := readQueuedJSON(t, admin) + if msg["ok"] != false { + t.Fatalf("reply = %+v, want ok:false when the audit file is disabled", msg) + } +} diff --git a/server/go/web/audit_test.go b/server/go/web/audit_test.go new file mode 100644 index 0000000..f48a866 --- /dev/null +++ b/server/go/web/audit_test.go @@ -0,0 +1,195 @@ +package web + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/yuanyuanxiang/SimpleRemoter/server/go/connection" + "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" + "github.com/yuanyuanxiang/SimpleRemoter/server/go/logger" + "github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth" +) + +// auditTestHub builds a wsHub whose logger writes both the main log and the +// dedicated audit log to a temp directory. +func auditTestHub(t *testing.T) (wh *wsHub, devices *hub.Hub, auditPath string) { + t.Helper() + dir := t.TempDir() + auditPath = filepath.Join(dir, "audit.log") + lg := logger.New(logger.Config{ + Level: logger.LevelInfo, + Console: false, + File: filepath.Join(dir, "server.log"), + AuditFile: auditPath, + }) + t.Cleanup(func() { _ = lg.Close() }) + + devices = hub.New() + wh = newWSHub(wsauth.New(), devices, nil, lg) + t.Cleanup(wh.stop) + return wh, devices, auditPath +} + +func auditTestClient(wh *wsHub, role string) (*wsClient, string) { + tok, err := wh.auth.IssueToken("tester", role) + if err != nil { + panic(err) + } + c := &wsClient{ + send: make(chan wsMsg, 64), + closed: make(chan struct{}), + id: newConnID(), + addr: "203.0.113.5", + peerAddr: "203.0.113.5", + connectedAt: time.Now(), + watchIDs: make(map[string]struct{}), + watchStartedAt: make(map[string]time.Time), + keyframeAt: make(map[string]int64), + } + wh.register(c) + return c, tok +} + +func readAuditEvents(t *testing.T, path string) []map[string]any { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatalf("open audit log: %v", err) + } + defer f.Close() + + var out []map[string]any + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("bad audit line %q: %v", line, err) + } + out = append(out, m) + } + if err := sc.Err(); err != nil { + t.Fatalf("scan audit log: %v", err) + } + return out +} + +func findAuditEvent(t *testing.T, path, event string) map[string]any { + t.Helper() + for _, m := range readAuditEvents(t, path) { + if m["event"] == event { + return m + } + } + t.Fatalf("audit event %q not found in %v", event, readAuditEvents(t, path)) + return nil +} + +func TestLoginFailureIsAudited(t *testing.T) { + wh, _, auditPath := auditTestHub(t) + wh.auth.AddAdminFromPlainPassword("admin", "hunter2") + c, _ := auditTestClient(wh, "viewer") + + nonce, err := wsauth.NewNonce() + if err != nil { + t.Fatalf("NewNonce: %v", err) + } + c.nonce = nonce + raw, _ := json.Marshal(map[string]any{ + "username": "admin", + "response": "not-the-right-response", + "nonce": nonce, + }) + wh.handleLogin(c, raw) + + rec := findAuditEvent(t, auditPath, auditLoginFail) + if rec["reason"] != "invalid_credentials" { + t.Errorf("reason = %v", rec["reason"]) + } + if rec["username"] != "admin" { + t.Errorf("username = %v", rec["username"]) + } + if rec["user_exists"] != true { + t.Errorf("user_exists = %v, want true", rec["user_exists"]) + } + if rec["ip"] != "203.0.113.5" { + t.Errorf("ip = %v", rec["ip"]) + } + if _, ok := rec["response"]; ok { + t.Error("audit record leaked the login response") + } +} + +func TestAdminDenialIsAudited(t *testing.T) { + wh, _, auditPath := auditTestHub(t) + c, tok := auditTestClient(wh, "viewer") + raw, _ := json.Marshal(map[string]any{"token": tok}) + + if wh.requireAdmin(c, raw, "delete_device_result") { + t.Fatal("viewer passed requireAdmin") + } + rec := findAuditEvent(t, auditPath, auditDenied) + if rec["operation"] != "delete_device" { + t.Errorf("operation = %v", rec["operation"]) + } + if rec["role"] != "viewer" || rec["actor"] != "tester" { + t.Errorf("identity fields = %+v", rec) + } +} + +func TestScreenStartAndOfflineStopAreAudited(t *testing.T) { + wh, devices, auditPath := auditTestHub(t) + devices.SetSender(func(*connection.Context, []byte) error { return nil }) + hctx := &connection.Context{RemoteAddr: "10.0.0.9:1234"} + devices.Register(&hub.Device{ID: "dev1", Name: "Dev"}, hctx) + + c, tok := auditTestClient(wh, "admin") + raw, _ := json.Marshal(map[string]any{ + "token": tok, "id": "dev1", "multiWatch": true, "req": "1", + }) + wh.handleConnect(c, raw) + + start := findAuditEvent(t, auditPath, auditScreenStart) + if start["device_id"] != "dev1" || start["mode"] != "started" { + t.Errorf("screen start = %+v", start) + } + + wh.OnDeviceOffline("dev1") + + stop := findAuditEvent(t, auditPath, auditScreenStop) + if stop["device_id"] != "dev1" || stop["reason"] != "device_offline" { + t.Errorf("screen stop = %+v", stop) + } + if _, ok := stop["duration_ms"]; !ok { + t.Error("screen stop has no duration_ms") + } +} + +func TestUnknownCommandRecordedOnce(t *testing.T) { + wh, _, auditPath := auditTestHub(t) + c, _ := auditTestClient(wh, "viewer") + + for i := 0; i < 5; i++ { + wh.dispatch(c, "definitely_not_a_command", []byte(`{}`)) + } + count := 0 + for _, m := range readAuditEvents(t, auditPath) { + if m["event"] == auditUnknownCmd { + count++ + } + } + if count != 1 { + t.Fatalf("unknown-command audit records = %d, want 1", count) + } + if c.statUnknownCmd != 5 { + t.Fatalf("statUnknownCmd = %d, want 5", c.statUnknownCmd) + } +} diff --git a/server/go/web/sso.go b/server/go/web/sso.go index a178fac..ad307cb 100644 --- a/server/go/web/sso.go +++ b/server/go/web/sso.go @@ -33,26 +33,47 @@ func (s *Server) handleSSO(w http.ResponseWriter, r *http.Request) { } username, role, ok := s.sso.Detect(r) if !ok { + // No usable portal credential: the normal fallback to the login form. + // Not audited — portal users without YAMA access hit this routinely. s.unauthorized(w) return } // Only the "admin" role may skip the login form via SSO. Every other role // (viewer, unknown) must authenticate against YAMA's own login, even with - // a valid portal session. + // a valid portal session. A valid portal identity being refused here is + // worth an audit record: it is an authorization decision, not a login-form + // fallback. if role != "admin" { + fields := requestFields(r, s.trustForwardedFor) + fields["method"] = "sso" + fields["actor"] = username + fields["role"] = role + fields["reason"] = "role_not_allowed" + s.log.AuditWarn(auditLoginFail, fields) s.unauthorized(w) return } token, err := s.auth.IssueToken(username, role) if err != nil { + fields := requestFields(r, s.trustForwardedFor) + fields["method"] = "sso" + fields["actor"] = username + fields["role"] = role + fields["reason"] = "token_issue_failed" + fields["error"] = err.Error() + s.log.AuditWarn(auditLoginFail, fields) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"internal error"}`)) return } - s.log.Info("sso: user=%s role=%s", username, role) + fields := requestFields(r, s.trustForwardedFor) + fields["method"] = "sso" + fields["actor"] = username + fields["role"] = role + s.log.Audit(auditLoginOK, fields) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]string{ diff --git a/server/go/web/ws.go b/server/go/web/ws.go index a98f6fc..1109673 100644 --- a/server/go/web/ws.go +++ b/server/go/web/ws.go @@ -48,11 +48,19 @@ type wsClient struct { closed chan struct{} once sync.Once + // Immutable connection metadata (set once in serve). + id string // short random id for audit correlation + addr string // resolved client IP for logs / rate limits + peerAddr string // actual TCP peer (differs from addr behind a proxy) + origin string + userAgent string + connectedAt time.Time + // Mutated under wsHub.mu (or only by the read loop owning this client). nonce string // outstanding challenge — cleared after a successful login token string // set once authenticated role string // mirrors session role after login - addr string // client address for logs + user string // mirrors session username after login/resume termWatching string // device ID for an open web terminal session, "" otherwise // watchIDs is the set of devices this browser is streaming (the screen @@ -60,9 +68,30 @@ type wsClient struct { // Guarded by wsHub.mu. watchIDs map[string]struct{} + // watchStartedAt records when each watch began so stop events can carry a + // duration. Guarded by wsHub.mu. + watchStartedAt map[string]time.Time + // keyframeAt throttles keyframe requests to one per device per second. // Unix seconds, guarded by wsHub.mu. keyframeAt map[string]int64 + + // Terminal session bookkeeping. Guarded by wsHub.mu. + termStartedAt time.Time + termCloseReason string + + // Audit counters, owned by the read-loop goroutine and read in unregister + // after that goroutine returns. + statCommands int + statInvalidToken int + statLoginFailures int + statAdminDenied int + statUnknownSalt int + statUnknownCmd int + + auditInvalidTokenLogged bool + auditUnknownSaltLogged bool + auditUnknownCmdLogged bool } // queue writes a JSON text frame onto the send buffer. Drops silently if the @@ -97,7 +126,7 @@ func (c *wsClient) close() { // challengeCaps advertises optional protocol features. It is appended to every // challenge, including the rotation after a failed login, because the browser // overwrites its capability state on each challenge. Old frontends ignore it. -const challengeCaps = `,"caps":{"multiWatch":true,"keyframeRequest":true,"offlineDevices":true}` +const challengeCaps = `,"caps":{"multiWatch":true,"keyframeRequest":true,"offlineDevices":true,"auditLog":true}` // maxWatchPerClient bounds the number of devices one WebSocket may stream at // once. The wall tops out at 25 tiles; the slack covers a focus switch racing @@ -286,8 +315,12 @@ func (h *wsHub) OnDeviceOffline(id string) { // connection no longer holds a subscription for it. The browser does the // same with its local refcount. Without this, a stale entry would keep // countWatchers non-zero and later block the screen teardown. + type viewer struct { + c *wsClient + start time.Time + } + var stopped []viewer h.mu.Lock() - defer h.mu.Unlock() for c := range h.clients { if !c.isWatching(id) { continue @@ -297,6 +330,19 @@ func (h *wsHub) OnDeviceOffline(id string) { if c.token != "" { c.queue(msg) } + if !c.watchStartedAt[id].IsZero() { + stopped = append(stopped, viewer{c, c.watchStartedAt[id]}) + } + delete(c.watchStartedAt, id) + } + h.mu.Unlock() + // Audit outside h.mu: h.audit takes h.mu.RLock. + for _, v := range stopped { + h.audit(v.c, false, auditScreenStop, map[string]any{ + "device_id": id, + "duration_ms": durationMs(v.start), + "reason": "device_offline", + }) } } @@ -415,14 +461,35 @@ func (h *wsHub) OnTerminalClosed(deviceID string, reason string) { "ok": true, "reason": reason, }) + type closed struct { + c *wsClient + start time.Time + why string + } + var done []closed h.mu.Lock() - defer h.mu.Unlock() for c := range h.clients { if c.termWatching == deviceID && c.token != "" { + why := c.termCloseReason + if why == "" { + why = reason + } + done = append(done, closed{c, c.termStartedAt, why}) c.termWatching = "" + c.termStartedAt = time.Time{} + c.termCloseReason = "" c.queue(msg) } } + h.mu.Unlock() + // Audit outside h.mu: h.audit takes h.mu.RLock. + for _, d := range done { + h.audit(d.c, false, auditTermStop, map[string]any{ + "device_id": deviceID, + "duration_ms": durationMs(d.start), + "reason": d.why, + }) + } } // OnDeviceUpdate forwards heartbeat-derived liveness data so the device-list @@ -467,26 +534,49 @@ func (h *wsHub) unregister(c *wsClient) { // below must run OUTSIDE the lock: countWatchers takes h.mu.RLock and // deferredCloseScreen takes h.mu.Lock. watched := make([]string, 0, len(c.watchIDs)) + started := make(map[string]time.Time, len(c.watchIDs)) for id := range c.watchIDs { watched = append(watched, id) + started[id] = c.watchStartedAt[id] } c.watchIDs = nil + c.watchStartedAt = nil c.keyframeAt = nil + + termDevice := c.termWatching + termStart := c.termStartedAt + c.termWatching = "" + c.termStartedAt = time.Time{} + c.termCloseReason = "" h.mu.Unlock() + // If this client was the last viewer of a device, tear down the screen // session so the device stops encoding. for _, id := range watched { + if !started[id].IsZero() { + h.audit(c, false, auditScreenStop, map[string]any{ + "device_id": id, + "duration_ms": durationMs(started[id]), + "reason": "ws_close", + }) + } if h.countWatchers(id) == 0 { h.deferredCloseScreen(id) } } // Terminal sessions are single-viewer by design, so any open session // belongs to this client. Tear it down so the next viewer doesn't - // hit ErrTerminalBusy from an abandoned session. - if c.termWatching != "" { - h.devices.CloseTerminalSession(c.termWatching) - c.termWatching = "" + // hit ErrTerminalBusy from an abandoned session. The client is already + // out of h.clients, so OnTerminalClosed won't reach it — audit here. + if termDevice != "" { + h.devices.CloseTerminalSession(termDevice) + h.audit(c, false, auditTermStop, map[string]any{ + "device_id": termDevice, + "duration_ms": durationMs(termStart), + "reason": "ws_close", + }) } + h.auditClose(c, len(watched)) // Do NOT revoke the token: tokens are session-scoped, not WS-scoped. // Frontend may close+reopen the WS at any time (visibilitychange handler, // brief network blip, reload) and must be able to resume with the same @@ -521,6 +611,16 @@ func (h *wsHub) deferredCloseScreen(deviceID string) { // ----- HTTP handler ------------------------------------------------------- func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) { + // Reject disallowed origins explicitly so the rejection is auditable. + // Upgrader.CheckOrigin below is kept as defense in depth. + if !h.checkOrigin(r) { + fields := requestFields(r, h.trustForwardedFor) + fields["reason"] = "origin_not_allowed" + h.log.AuditWarn(auditWSReject, fields) + http.Error(w, "origin not allowed", http.StatusForbidden) + return + } + // Build a per-call upgrader so CheckOrigin closes over this hub's // allowlist instead of a package-level mutable. upgrader := baseUpgrader @@ -528,7 +628,10 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { - h.log.Error("ws upgrade: %v", err) + fields := requestFields(r, h.trustForwardedFor) + fields["reason"] = "upgrade_failed" + fields["error"] = err.Error() + h.log.AuditWarn(auditWSReject, fields) return } conn.SetReadLimit(wsReadLimit) @@ -541,15 +644,22 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) { } client := &wsClient{ - conn: conn, - send: make(chan wsMsg, wsSendBuffer), - closed: make(chan struct{}), - nonce: nonce, - addr: clientIP(r, h.trustForwardedFor), - watchIDs: make(map[string]struct{}), - keyframeAt: make(map[string]int64), + conn: conn, + send: make(chan wsMsg, wsSendBuffer), + closed: make(chan struct{}), + id: newConnID(), + addr: clientIP(r, h.trustForwardedFor), + peerAddr: stripPort(r.RemoteAddr), + origin: r.Header.Get("Origin"), + userAgent: r.UserAgent(), + connectedAt: time.Now(), + nonce: nonce, + watchIDs: make(map[string]struct{}), + watchStartedAt: make(map[string]time.Time), + keyframeAt: make(map[string]int64), } h.register(client) + h.audit(client, false, auditWSConnect, client.wsRequestFields()) defer h.unregister(client) go h.writeLoop(client) diff --git a/server/go/web/ws_handlers.go b/server/go/web/ws_handlers.go index a6c896c..4403645 100644 --- a/server/go/web/ws_handlers.go +++ b/server/go/web/ws_handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "sort" + "strings" "time" "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" @@ -38,6 +39,9 @@ func (h *wsHub) rotateChallenge(c *wsClient) { // Phase 6 adds: term_open / term_input / term_resize / term_close (PTY relay). // Phase 7 covers admin: create_user / delete_user / list_users / get_groups. func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) { + // Connection-scoped counters feed the web.ws.close audit summary, which + // is what turns a silent port scan into a visible event. + c.statCommands++ switch cmd { case "get_salt": h.handleGetSalt(c, raw) @@ -93,6 +97,19 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) { h.handleListUsers(c, raw) case "get_groups": h.handleGetGroups(c, raw) + case "query_audit": + h.handleQueryAudit(c, raw) + default: + // Unknown commands are attacker-controlled and can be sent in a tight + // loop. Count them for the close summary and record only the first + // one per connection so the audit trail cannot be flooded. + c.statUnknownCmd++ + if !c.auditUnknownCmdLogged { + c.auditUnknownCmdLogged = true + h.audit(c, true, auditUnknownCmd, map[string]any{ + "command": cmd, + }) + } } } @@ -105,6 +122,11 @@ func (h *wsHub) requireAdmin(c *wsClient, raw []byte, replyCmd string) (ok bool) } // c.role is cached on first successful requireAuth; safe to read here. if c.role != "admin" { + c.statAdminDenied++ + h.audit(c, true, auditDenied, map[string]any{ + "operation": strings.TrimSuffix(replyCmd, "_result"), + "reason": "not_admin", + }) c.queue(mustJSON(map[string]any{ "cmd": replyCmd, "ok": false, "msg": "Permission denied", })) @@ -133,7 +155,21 @@ func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) { } _ = json.Unmarshal(raw, &in) - salt, _ := h.auth.GetSalt(in.Username) + salt, exists := h.auth.GetSalt(in.Username) + // The response deliberately hides whether the user exists (fake salt), + // but the audit trail should not: repeated probes for a nonexistent + // account are a strong signal of username enumeration. Record the first + // probe per connection only; the close summary carries the total. + if !exists { + c.statUnknownSalt++ + if !c.auditUnknownSaltLogged { + c.auditUnknownSaltLogged = true + h.audit(c, true, auditSaltProbe, map[string]any{ + "username": in.Username, + "reason": "unknown_user", + }) + } + } // GetSalt now returns a deterministic fake salt (16 hex chars) for // unknown users — same shape as a real salt — so an attacker can't // tell from this response alone whether the username exists. @@ -151,6 +187,8 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) { Nonce string `json:"nonce"` } if err := json.Unmarshal(raw, &in); err != nil { + c.statLoginFailures++ + h.audit(c, true, auditLoginFail, map[string]any{"reason": "malformed_request"}) c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid request"})) return } @@ -161,7 +199,11 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) { // known account (admin). Either dimension tripping rejects the call // with a uniform "credentials" error so the limit is not detectable. if !h.allowLoginByIP(c) || !h.allowLoginByUsername(in.Username) { - h.log.Warn("ws login throttled: user=%s addr=%s", in.Username, c.addr) + c.statLoginFailures++ + h.audit(c, true, auditLoginThrot, map[string]any{ + "username": in.Username, + "reason": "rate_limited", + }) time.Sleep(500 * time.Millisecond) c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"})) // Rotate the challenge: burns the previous nonce (replay protection) @@ -174,6 +216,11 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) { // Bind the response to the challenge we issued at connect time so that // replays from a different connection can't reuse a captured response. if in.Nonce == "" || in.Nonce != c.nonce { + c.statLoginFailures++ + h.audit(c, true, auditLoginFail, map[string]any{ + "username": in.Username, + "reason": "invalid_challenge", + }) c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid challenge"})) h.rotateChallenge(c) return @@ -181,6 +228,16 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) { token, role, err := h.auth.VerifyLogin(in.Username, in.Response, in.Nonce) if err != nil { + // Record whether the account exists — an attack that keeps hammering + // a real account (or scans for names) reads very differently from a + // user mistyping their own password. + _, exists := h.auth.GetSalt(in.Username) + c.statLoginFailures++ + h.audit(c, true, auditLoginFail, map[string]any{ + "username": in.Username, + "reason": "invalid_credentials", + "user_exists": exists, + }) // Fixed delay on failure: makes online brute force impractical // even within the rate-limit budget, and erases the timing // difference between "wrong password" and "wrong nonce". @@ -193,10 +250,20 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) { // user who fat-fingered a few times doesn't stay throttled. h.resetLoginThrottle(c, in.Username) + // Identity fields are read by audit() from hub goroutines, so publish + // them under h.mu. + h.mu.Lock() c.nonce = "" c.token = token c.role = role - h.log.Info("ws login: user=%s role=%s addr=%s", in.Username, role, c.addr) + c.user = in.Username + h.mu.Unlock() + + fields := c.wsRequestFields() + fields["method"] = "password" + fields["actor"] = in.Username + fields["role"] = role + h.audit(c, false, auditLoginOK, fields) c.queue(mustJSON(map[string]any{ "cmd": "login_result", @@ -257,6 +324,7 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) { // Take h.mu so event-handler readers (OnResolutionChange/OnScreenFrame) // get a consistent view of the watch set. var closed []string + starts := make(map[string]time.Time) h.mu.Lock() if in.MultiWatch && in.ID != "" { // New frontend: drop only this device; the other wall tiles keep @@ -264,6 +332,8 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) { if c.isWatching(in.ID) { delete(c.watchIDs, in.ID) delete(c.keyframeAt, in.ID) + starts[in.ID] = c.watchStartedAt[in.ID] + delete(c.watchStartedAt, in.ID) closed = append(closed, in.ID) } } else { @@ -271,9 +341,11 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) { // one device, so clear everything. for id := range c.watchIDs { closed = append(closed, id) + starts[id] = c.watchStartedAt[id] } c.watchIDs = make(map[string]struct{}) c.keyframeAt = make(map[string]int64) + c.watchStartedAt = make(map[string]time.Time) } h.mu.Unlock() @@ -288,6 +360,13 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) { // deferredCloseScreen takes h.mu, so it must run outside the lock above. for _, id := range closed { + if !starts[id].IsZero() { + h.audit(c, false, auditScreenStop, map[string]any{ + "device_id": id, + "duration_ms": durationMs(starts[id]), + "reason": "disconnect", + }) + } if h.countWatchers(id) == 0 { h.deferredCloseScreen(id) } @@ -393,8 +472,10 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) { } var stopped []string - var tooMany bool + starts := make(map[string]time.Time) + var tooMany, already bool h.mu.Lock() + _, already = c.watchIDs[in.ID] stopped, tooMany = applyConnectWatch(c, in.ID, in.MultiWatch) // A pending deferred close means the previous session is still alive for // a few seconds; cancel it so the cached resolution + IDR hot path wins. @@ -406,10 +487,24 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) { t.Stop() delete(h.screenCloseTimers, in.ID) } + // Repeated connect (focus switch / wall re-subscribe) keeps the + // original start time so its audit duration covers the whole watch. + if _, ok := c.watchStartedAt[in.ID]; !ok { + c.watchStartedAt[in.ID] = time.Now() + } + for _, id := range stopped { + starts[id] = c.watchStartedAt[id] + delete(c.watchStartedAt, id) + delete(c.keyframeAt, id) + } } h.mu.Unlock() if tooMany { + h.audit(c, true, auditScreenDenied, map[string]any{ + "device_id": in.ID, + "reason": "too_many_watches", + }) c.queue(mustJSON(reply(false, "Too many devices on one connection"))) return } @@ -417,6 +512,13 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) { // screen sessions are torn down outside the lock; deferredCloseScreen is // a no-op while another browser still watches the device. for _, id := range stopped { + if !starts[id].IsZero() { + h.audit(c, false, auditScreenStop, map[string]any{ + "device_id": id, + "duration_ms": durationMs(starts[id]), + "reason": "replaced", + }) + } if h.countWatchers(id) == 0 { h.deferredCloseScreen(id) } @@ -429,6 +531,15 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) { // next IDR (~15 s). cache := h.devices.ScreenState(in.ID) if cache.Active { + if !already { + h.audit(c, false, auditScreenStart, map[string]any{ + "device_id": in.ID, + "mode": "cached", + "multi_watch": in.MultiWatch, + "width": cache.Width, + "height": cache.Height, + }) + } m := reply(true, "") m["width"] = cache.Width m["height"] = cache.Height @@ -461,16 +572,30 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) { h.mu.Lock() delete(c.watchIDs, in.ID) delete(c.keyframeAt, in.ID) + delete(c.watchStartedAt, in.ID) h.mu.Unlock() msg := "Device offline" + reason := "device_offline" if err != hub.ErrDeviceOffline { msg = "Failed to start screen capture" + reason = "start_failed" h.log.Error("SendToDevice(%s): %v", in.ID, err) } + h.audit(c, true, auditScreenDenied, map[string]any{ + "device_id": in.ID, + "reason": reason, + }) c.queue(mustJSON(reply(false, msg))) return } h.log.Debug("[timing] COMMAND_SCREEN_SPY sent to device=%s (cold start)", in.ID) + if !already { + h.audit(c, false, auditScreenStart, map[string]any{ + "device_id": in.ID, + "mode": "started", + "multi_watch": in.MultiWatch, + }) + } c.queue(mustJSON(reply(true, ""))) } @@ -561,7 +686,10 @@ func (h *wsHub) handleRdpReset(c *wsClient, raw []byte) { } deviceID := h.resolveWatchDevice(c, raw) if deviceID == "" { - h.log.Warn("rdp_reset: no device watched (addr=%s role=%s)", c.addr, c.role) + h.audit(c, true, auditRdpReset, map[string]any{ + "ok": false, + "reason": "no_device", + }) return } // CMD_RESTORE_CONSOLE must go through the screen sub-conn — the client @@ -569,10 +697,18 @@ func (h *wsHub) handleRdpReset(c *wsClient, raw []byte) { // the screen sub-conn (see client/ScreenManager.cpp:996). Sending on the // main conn would silently no-op. if err := h.devices.SendToScreen(deviceID, []byte{protocol.CmdRestoreConsole}); err != nil { - h.log.Warn("rdp_reset: device=%s: %v", deviceID, err) + h.audit(c, true, auditRdpReset, map[string]any{ + "device_id": deviceID, + "ok": false, + "reason": "send_failed", + "error": err.Error(), + }) return } - h.log.Info("rdp_reset sent: device=%s", deviceID) + h.audit(c, false, auditRdpReset, map[string]any{ + "device_id": deviceID, + "ok": true, + }) } func (h *wsHub) handleGetDevices(c *wsClient, raw []byte) { @@ -614,21 +750,46 @@ func (h *wsHub) requireAuth(c *wsClient, raw []byte, replyCmd string) bool { } _ = json.Unmarshal(raw, &in) if in.Token == "" { + h.auditBadToken(c, replyCmd, "missing_token") c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false})) return false } sess, err := h.auth.ValidateToken(in.Token) if err != nil { + h.auditBadToken(c, replyCmd, "invalid_token") c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false})) return false } + resumed := false + h.mu.Lock() if c.token == "" { c.token = in.Token c.role = sess.Role + c.user = sess.Username + resumed = true + } + h.mu.Unlock() + if resumed { + h.audit(c, false, auditSession, map[string]any{"method": "token"}) } return true } +// auditBadToken counts rejected tokens and records only the first one per +// connection: an attacker can loop protected commands with garbage tokens, +// and one line plus the close-summary count is enough to see it. +func (h *wsHub) auditBadToken(c *wsClient, replyCmd, reason string) { + c.statInvalidToken++ + if c.auditInvalidTokenLogged { + return + } + c.auditInvalidTokenLogged = true + h.audit(c, true, auditBadToken, map[string]any{ + "command": strings.TrimSuffix(replyCmd, "_result"), + "reason": reason, + }) +} + // handleMouse forwards one mouse event from the browser to the device's // screen sub-connection as a COMMAND_SCREEN_CONTROL packet carrying a // single MSG64. Mirrors the C++ CWebService::HandleMouse path @@ -796,19 +957,39 @@ func (h *wsHub) handleClipboardSet(c *wsClient, raw []byte) { } deviceID := h.resolveWatchDevice(c, raw) if deviceID == "" { + h.audit(c, true, auditClipSet, map[string]any{ + "ok": false, + "reason": "no_device", + }) return } gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(in.Text)) if err != nil { - h.log.Warn("clipboard_set: GBK encode failed: %v", err) + h.audit(c, true, auditClipSet, map[string]any{ + "device_id": deviceID, + "ok": false, + "reason": "encode_failed", + "error": err.Error(), + }) return } payload := append([]byte{protocol.CommandScreenSetClipboard}, gbk...) if err := h.devices.SendToScreen(deviceID, payload); err != nil { - h.log.Warn("clipboard_set: device=%s: %v", deviceID, err) + h.audit(c, true, auditClipSet, map[string]any{ + "device_id": deviceID, + "ok": false, + "reason": "send_failed", + "error": err.Error(), + }) return } - h.log.Info("clipboard_set sent: device=%s len=%d", deviceID, len(gbk)) + // Record the size only — clipboard contents may be sensitive. + h.audit(c, false, auditClipSet, map[string]any{ + "device_id": deviceID, + "ok": true, + "bytes": len(gbk), + "source_utf8": len(in.Text), + }) } // handleClipboardGet asks the remote device for its clipboard text via @@ -820,12 +1001,25 @@ func (h *wsHub) handleClipboardGet(c *wsClient, raw []byte) { } deviceID := h.resolveWatchDevice(c, raw) if deviceID == "" { + h.audit(c, true, auditClipGet, map[string]any{ + "ok": false, + "reason": "no_device", + }) return } if err := h.devices.SendToScreen(deviceID, []byte{protocol.CommandScreenGetClipboard}); err != nil { - h.log.Warn("clipboard_get: device=%s: %v", deviceID, err) + h.audit(c, true, auditClipGet, map[string]any{ + "device_id": deviceID, + "ok": false, + "reason": "send_failed", + "error": err.Error(), + }) return } + h.audit(c, false, auditClipGet, map[string]any{ + "device_id": deviceID, + "ok": true, + }) } // handleCreateUser provisions a new web account. Admin-only. Mirrors the @@ -844,6 +1038,10 @@ func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) { AllowedGroups []string `json:"allowed_groups"` } if err := json.Unmarshal(raw, &in); err != nil { + h.audit(c, true, auditUserCreate, map[string]any{ + "ok": false, + "reason": "invalid_json", + }) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"})) return } @@ -851,11 +1049,22 @@ func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) { in.Role = "viewer" } if err := h.auth.CreateUser(in.Username, in.Password, in.Role, in.AllowedGroups); err != nil { + h.audit(c, true, auditUserCreate, map[string]any{ + "target": in.Username, + "target_role": in.Role, + "ok": false, + "reason": "create_failed", + "error": err.Error(), + }) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()})) return } - h.log.Info("user created by %s: name=%s role=%s groups=%d", - c.role, in.Username, in.Role, len(in.AllowedGroups)) + h.audit(c, false, auditUserCreate, map[string]any{ + "target": in.Username, + "target_role": in.Role, + "groups": len(in.AllowedGroups), + "ok": true, + }) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true})) } @@ -877,6 +1086,10 @@ func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) { ID string `json:"id"` } if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" { + h.audit(c, true, auditDeviceAdmin, map[string]any{ + "ok": false, + "reason": "invalid_id", + }) c.queue(mustJSON(map[string]any{ "cmd": replyCmd, "ok": false, "msg": "Invalid device id", })) @@ -893,7 +1106,11 @@ func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) { if h.history != nil { h.history.Remove(in.ID) } - h.log.Info("device uninstall requested by %s: id=%s (record removed)", c.role, in.ID) + h.audit(c, false, auditDeviceAdmin, map[string]any{ + "device_id": in.ID, + "mode": "uninstall", + "ok": true, + }) h.broadcastAuthenticated(`{"cmd":"devices_changed"}`) c.queue(mustJSON(map[string]any{ "cmd": replyCmd, "ok": true, "id": in.ID, "mode": "uninstall", @@ -903,7 +1120,13 @@ func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) { // Lost the race with a disconnect between IsOnline and the send: // fall through to removing the (now offline) history record. if !errors.Is(err, hub.ErrDeviceOffline) { - h.log.Warn("device uninstall failed for %s: %v", in.ID, err) + h.audit(c, true, auditDeviceAdmin, map[string]any{ + "device_id": in.ID, + "mode": "uninstall", + "ok": false, + "reason": "uninstall_failed", + "error": err.Error(), + }) c.queue(mustJSON(map[string]any{ "cmd": replyCmd, "ok": false, "msg": err.Error(), })) @@ -912,12 +1135,22 @@ func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) { } if h.history == nil || !h.history.Remove(in.ID) { + h.audit(c, true, auditDeviceAdmin, map[string]any{ + "device_id": in.ID, + "mode": "removed", + "ok": false, + "reason": "not_found", + }) c.queue(mustJSON(map[string]any{ "cmd": replyCmd, "ok": false, "msg": "Device not found", })) return } - h.log.Info("device record removed by %s: id=%s", c.role, in.ID) + h.audit(c, false, auditDeviceAdmin, map[string]any{ + "device_id": in.ID, + "mode": "removed", + "ok": true, + }) // Other browsers (and this one) refresh their lists; the reply below // still gives the initiator a precise result/toast. h.broadcastAuthenticated(`{"cmd":"devices_changed"}`) @@ -937,14 +1170,27 @@ func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) { Username string `json:"username"` } if err := json.Unmarshal(raw, &in); err != nil { + h.audit(c, true, auditUserDelete, map[string]any{ + "ok": false, + "reason": "invalid_json", + }) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"})) return } if err := h.auth.DeleteUser(in.Username); err != nil { + h.audit(c, true, auditUserDelete, map[string]any{ + "target": in.Username, + "ok": false, + "reason": "delete_failed", + "error": err.Error(), + }) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()})) return } - h.log.Info("user deleted by %s: name=%s", c.role, in.Username) + h.audit(c, false, auditUserDelete, map[string]any{ + "target": in.Username, + "ok": true, + }) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": true})) } @@ -1034,34 +1280,57 @@ func (h *wsHub) handleTermOpen(c *wsClient, raw []byte) { // screen path in handleConnect. h.mu.Lock() if c.termWatching != "" && c.termWatching != in.ID { + active := c.termWatching h.mu.Unlock() + h.audit(c, true, auditTermDenied, map[string]any{ + "device_id": in.ID, + "reason": "other_terminal_open", + "active": active, + }) c.queue(mustJSON(map[string]any{ "cmd": replyCmd, "ok": false, "msg": "Close current terminal before opening another", })) return } + newSession := c.termWatching != in.ID c.termWatching = in.ID + c.termCloseReason = "" + if newSession || c.termStartedAt.IsZero() { + c.termStartedAt = time.Now() + } h.mu.Unlock() if err := h.devices.OpenTerminalSession(in.ID); err != nil { h.mu.Lock() c.termWatching = "" + c.termStartedAt = time.Time{} h.mu.Unlock() msg := "Device offline" + reason := "device_offline" switch err { case hub.ErrTerminalBusy: msg = "Terminal already open by another viewer" + reason = "terminal_busy" case hub.ErrDeviceOffline: msg = "Device offline" default: msg = "Failed to start terminal" + reason = "start_failed" h.log.Error("OpenTerminalSession(%s): %v", in.ID, err) } + h.audit(c, true, auditTermDenied, map[string]any{ + "device_id": in.ID, + "reason": reason, + }) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": msg})) return } - h.log.Info("term_open: device=%s role=%s", in.ID, c.role) + if newSession { + h.audit(c, false, auditTermStart, map[string]any{ + "device_id": in.ID, + }) + } } // handleTermInput forwards xterm.js keystrokes to the device's shell @@ -1082,7 +1351,10 @@ func (h *wsHub) handleTermInput(c *wsClient, raw []byte) { if in.ID == "" || in.Data == "" { return } - if c.termWatching != in.ID { + h.mu.RLock() + watching := c.termWatching == in.ID + h.mu.RUnlock() + if !watching { return // someone else's session, or no session } _ = h.devices.SendToTerminal(in.ID, []byte(in.Data)) @@ -1106,7 +1378,10 @@ func (h *wsHub) handleTermResize(c *wsClient, raw []byte) { if in.ID == "" || in.Cols <= 0 || in.Rows <= 0 { return } - if c.termWatching != in.ID { + h.mu.RLock() + watching := c.termWatching == in.ID + h.mu.RUnlock() + if !watching { return } if !h.devices.TerminalIsPTY(in.ID) { @@ -1128,7 +1403,15 @@ func (h *wsHub) handleTermClose(c *wsClient, raw []byte) { if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" { return } - if c.termWatching != in.ID { + h.mu.Lock() + watching := c.termWatching == in.ID + if watching { + // Consumed by OnTerminalClosed for the remote.terminal.stop audit + // record (it fires synchronously inside CloseTerminalSession). + c.termCloseReason = "user" + } + h.mu.Unlock() + if !watching { return } h.devices.CloseTerminalSession(in.ID) diff --git a/server/web/index.html b/server/web/index.html index 07dfa3e..0aac27d 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -374,6 +374,107 @@ .user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; } .user-msg.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; } + /* ===== Audit Log page ===== */ + .icon-btn.audit { background: linear-gradient(135deg, #b9770e 0%, #e67e22 100%); display: none; } + .icon-btn.audit.visible { display: inline-flex; } + .icon-btn.audit:hover { box-shadow: 0 4px 12px rgba(230, 126, 34, 0.4); } + + #audit-page { max-width: 1500px; margin: 0 auto; } + .audit-title-wrap { display: flex; align-items: center; gap: 12px; } + .audit-filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 10px 14px; + background: rgba(22, 33, 62, 0.8); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 12px; + padding: 16px; + margin-bottom: 14px; + } + .audit-filters label { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 12px; + color: #9aa4b2; + } + .audit-filters input[type="text"], + .audit-filters input[type="datetime-local"], + .audit-filters select { + padding: 8px 10px; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + background: rgba(15, 52, 96, 0.7); + color: #fff; + font-size: 13px; + outline: none; + min-width: 0; + } + .audit-filters input:focus, .audit-filters select:focus { border-color: #e67e22; } + .audit-filters label.audit-warn { + flex-direction: row; + align-items: center; + padding-top: 18px; + } + .audit-filters label.audit-warn input { width: 15px; height: 15px; margin-right: 6px; } + .audit-actions { display: flex; gap: 10px; align-items: flex-end; } + .audit-actions button { + padding: 9px 18px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + } + .audit-search-btn { background: linear-gradient(135deg, #b9770e 0%, #e67e22 100%); color: #fff; } + .audit-search-btn:hover { transform: translateY(-1px); } + .audit-reset-btn { background: rgba(255,255,255,0.08); color: #ccc; } + .audit-reset-btn:hover { background: rgba(255,255,255,0.15); } + .audit-status { color: #888; font-size: 12px; margin-bottom: 10px; min-height: 16px; } + .audit-status.warn { color: #e67e22; } + .audit-table-wrap { + overflow-x: auto; + border: 1px solid rgba(255,255,255,0.06); + border-radius: 12px; + background: rgba(22, 33, 62, 0.8); + } + .audit-table { width: 100%; border-collapse: collapse; font-size: 12.5px; } + .audit-table th { + text-align: left; + padding: 10px 12px; + color: #9aa4b2; + font-weight: 600; + white-space: nowrap; + border-bottom: 1px solid rgba(255,255,255,0.1); + background: rgba(15, 52, 96, 0.5); + } + .audit-table td { + padding: 9px 12px; + border-bottom: 1px solid rgba(255,255,255,0.04); + color: #d5dbe3; + vertical-align: top; + white-space: nowrap; + } + .audit-table tr:last-child td { border-bottom: none; } + .audit-table tr:hover td { background: rgba(230, 126, 34, 0.06); } + .audit-table td.audit-details { + white-space: normal; + word-break: break-all; + color: #8f9aa8; + min-width: 220px; + font-family: ui-monospace, Consolas, monospace; + font-size: 11.5px; + } + .audit-level { padding: 2px 8px; border-radius: 10px; font-size: 11px; } + .audit-level.info { background: rgba(76, 175, 80, 0.15); color: #4caf50; } + .audit-level.warn { background: rgba(230, 126, 34, 0.18); color: #e67e22; } + .audit-empty { padding: 40px; text-align: center; color: #666; } + @media (max-width: 640px) { + .audit-filters { grid-template-columns: 1fr 1fr; } + .audit-actions { grid-column: 1 / -1; } + .audit-table td, .audit-table th { padding: 8px; } + } + /* Generic confirmation modal (compact yes/no dialog, e.g. logout) —— 复用 .modal-overlay 做遮罩,自带一套紧凑布局 + 危险/取消双按钮风格。 */ .confirm-modal-content { @@ -1517,6 +1618,14 @@ + +

Audit Log 0

+ +
+ +
+ +
+ + + + + + + + + + +
+ + +
+
+
+
+ + + + + + + + +
TimeLevelEventActorRoleIPDeviceDetails
+
+ + +