Feature: Security audit logging with admin web query page

Add a dedicated security audit trail to the Go server. Login attempts,
authorization denials, WebSocket lifecycle, remote screen and terminal
sessions, clipboard operations, device authentication and admin mutations
now emit stable JSON-line events (audit=true, event=<name>) to a separate
logs/audit.log, mirrored to server.log. The path is configurable through
YAMA_AUDIT_LOG and the separate file can be switched off. Records never
contain passcodes, password hashes, tokens or clipboard contents, and
untrusted string fields are stripped of control characters and truncated
so they cannot forge log lines.

Expose the trail to administrators through a query_audit WebSocket command
and an Audit Log page in the web UI. The page filters by time range, event
(exact or prefix), actor, IP, device id, keyword and warning level, pages
newest-first, and stays hidden unless the server advertises the auditLog
capability, so older C++ servers degrade cleanly. Each query scans at most
the newest 32 MB of the current file to bound latency, records itself as
admin.audit.query, and tolerates torn or non-audit lines.

Add unit tests for event emission and redaction, query filtering, paging,
malformed lines, missing or disabled audit files, and handler
authorization.

Co-Authored-By: deepseek-flash
This commit is contained in:
yuanyuanxianganddeepseek-flash committed 2026-10-18 18:44:17 +02:00
1 parent b1ddec1b8a
commit a522424491
12 files changed
+2264 -80

No files matched your search

+42 -1
View File
@@ -141,7 +141,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。
./simpleremoter-server ./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_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_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_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. | `<shared-secret>` | | `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. | `<shared-secret>` |
```bash ```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)"} {"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++ 版本兼容: 数据包格式与 C++ 版本兼容:
+79 -17
View File
@@ -269,7 +269,12 @@ func (h *MyHandler) rejectConnAuth(ctx *connection.Context, status byte) {
if err := h.srv.Send(ctx, ack); err != nil { 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.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() _ = 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 // closed term_close already), or a hijack attempt from a source IP
// other than the device's login conn. Log the latter distinctly. // other than the device's login conn. Log the latter distinctly.
if mc := h.hub.MainConn(devID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() { 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", h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{
devID, ctx.ID, ctx.GetPeerIP(), mc.GetPeerIP()) "kind": "terminal",
"device_id": devID,
"conn": ctx.ID,
"ip": ctx.GetPeerIP(),
"want_ip": mc.GetPeerIP(),
})
} else { } else {
h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v", h.log.Warn("orphan terminal sub-conn: device=%s conn=%d isPTY=%v",
devID, ctx.ID, isPTY) 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. // MasterID from a different source IP than the device's login conn.
// The latter is a security signal worth surfacing distinctly. // The latter is a security signal worth surfacing distinctly.
if mc := h.hub.MainConn(deviceID); mc != nil && mc.GetPeerIP() != ctx.GetPeerIP() { 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", h.log.AuditWarn("device.subconn.foreign_ip", map[string]any{
deviceID, ctx.ID, ctx.GetPeerIP(), mc.GetPeerIP()) "kind": "screen",
"device_id": deviceID,
"conn": ctx.ID,
"ip": ctx.GetPeerIP(),
"want_ip": mc.GetPeerIP(),
})
} else { } else {
h.log.Warn("orphan screen sub-conn %d for unknown device %s; closing", ctx.ID, deviceID) 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. // definitive answer instead of hanging, while a brute-forcer is capped.
ip := ctx.GetPeerIP() ip := ctx.GetPeerIP()
if !h.loginLimit.Allow(ip) { 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: "尝试过于频繁,请稍后再试"}) resp := h.auth.BuildResponse(&auth.AuthResult{Valid: false, Message: "尝试过于频繁,请稍后再试"})
if err := h.srv.Send(ctx, resp); err != nil { 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) 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" 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 { if result.Valid {
h.log.Info("Auth %s success: clientID=%s computer=%s ip=%s sn=%s passcode=%s", h.log.Audit("device.auth.success", fields)
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
} else { } else {
h.log.Warn("Auth %s failed: clientID=%s computer=%s ip=%s sn=%s passcode=%s", h.log.AuditWarn("device.auth.failure", fields)
authType, info.ClientID, info.ComputerName, ctx.GetPeerIP(), result.SN, result.Passcode)
} }
// Build and send response // Build and send response
@@ -707,15 +737,22 @@ func (h *MyHandler) handleHeartbeat(ctx *connection.Context, data []byte) {
if !ctx.IsAuthorized.Load() { if !ctx.IsAuthorized.Load() {
ctx.IsAuthorized.Store(true) ctx.IsAuthorized.Store(true)
info := ctx.GetInfo() info := ctx.GetInfo()
version := "V1"
if authResult.IsV2 { if authResult.IsV2 {
// V2 authorization version = "V2"
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)
} }
// 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.MaxAge = 30 // 30 days
logCfg.Compress = true 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 := logger.New(logCfg)
log.Info("====== Copyright (c) 2026 simpleremoter.com. All rights resvered. ======") 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 // 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 // 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.Warn("device history flush: %v", err)
} }
log.Info("Server stopped") 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()
} }
+183 -14
View File
@@ -1,10 +1,12 @@
package logger package logger
import ( import (
"fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"time" "strings"
"unicode/utf8"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"gopkg.in/natefinch/lumberjack.v2" "gopkg.in/natefinch/lumberjack.v2"
@@ -24,6 +26,13 @@ const (
// Logger wraps zerolog.Logger // Logger wraps zerolog.Logger
type Logger struct { type Logger struct {
zl zerolog.Logger 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 // Config holds logger configuration
@@ -42,6 +51,19 @@ type Config struct {
MaxAge int MaxAge int
// Compress enables gzip compression for rotated files // Compress enables gzip compression for rotated files
Compress bool 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=<name>, 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 // DefaultConfig returns default configuration
@@ -60,6 +82,7 @@ func DefaultConfig() Config {
// New creates a new logger with config // New creates a new logger with config
func New(cfg Config) *Logger { func New(cfg Config) *Logger {
var writers []io.Writer var writers []io.Writer
var closers []io.Closer
// Console output with color // Console output with color
if cfg.Console { if cfg.Console {
@@ -87,6 +110,7 @@ func New(cfg Config) *Logger {
LocalTime: true, LocalTime: true,
} }
writers = append(writers, fileWriter) writers = append(writers, fileWriter)
closers = append(closers, fileWriter)
} }
// Combine writers // Combine writers
@@ -106,14 +130,78 @@ func New(cfg Config) *Logger {
Timestamp(). Timestamp().
Logger() 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 // WithPrefix returns a new logger with a prefix field
func (l *Logger) WithPrefix(prefix string) *Logger { func (l *Logger) WithPrefix(prefix string) *Logger {
return &Logger{ n := &Logger{zl: l.zl.With().Str("module", prefix).Logger(), auditPath: l.auditPath}
zl: l.zl.With().Str("module", prefix).Logger(), 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 // Debug logs a debug message
@@ -151,21 +239,102 @@ func (l *Logger) GetLevel() Level {
return l.zl.GetLevel() 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) { func (l *Logger) ClientEvent(event string, clientID uint64, ip string, extra ...string) {
e := l.zl.Info(). // clientID is the TCP connection id; the device's stable YAMA id arrives
Str("event", event). // as the "clientID" extra and is normalized to device_id so every audit
Uint64("client_id", clientID). // record uses the same field names (conn / device_id).
Str("ip", ip). fields := map[string]any{
Time("time", time.Now()) "conn": clientID,
"ip": ip,
if len(extra) >= 2 { }
for i := 0; i+1 < len(extra); i += 2 { for i := 0; i+1 < len(extra); i += 2 {
e = e.Str(extra[i], extra[i+1]) 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 // default global logger
+156
View File
@@ -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"])
}
}
+171
View File
@@ -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[:])
}
+328
View File
@@ -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
}
+294
View File
@@ -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)
}
}
+195
View File
@@ -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)
}
}
+23 -2
View File
@@ -33,26 +33,47 @@ func (s *Server) handleSSO(w http.ResponseWriter, r *http.Request) {
} }
username, role, ok := s.sso.Detect(r) username, role, ok := s.sso.Detect(r)
if !ok { 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) s.unauthorized(w)
return return
} }
// Only the "admin" role may skip the login form via SSO. Every other role // 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 // (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" { 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) s.unauthorized(w)
return return
} }
token, err := s.auth.IssueToken(username, role) token, err := s.auth.IssueToken(username, role)
if err != nil { 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal error"}`)) _, _ = w.Write([]byte(`{"error":"internal error"}`))
return 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("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]string{ _ = json.NewEncoder(w).Encode(map[string]string{
+120 -10
View File
@@ -48,11 +48,19 @@ type wsClient struct {
closed chan struct{} closed chan struct{}
once sync.Once 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). // Mutated under wsHub.mu (or only by the read loop owning this client).
nonce string // outstanding challenge — cleared after a successful login nonce string // outstanding challenge — cleared after a successful login
token string // set once authenticated token string // set once authenticated
role string // mirrors session role after login 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 termWatching string // device ID for an open web terminal session, "" otherwise
// watchIDs is the set of devices this browser is streaming (the screen // watchIDs is the set of devices this browser is streaming (the screen
@@ -60,9 +68,30 @@ type wsClient struct {
// Guarded by wsHub.mu. // Guarded by wsHub.mu.
watchIDs map[string]struct{} 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. // keyframeAt throttles keyframe requests to one per device per second.
// Unix seconds, guarded by wsHub.mu. // Unix seconds, guarded by wsHub.mu.
keyframeAt map[string]int64 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 // 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 // challengeCaps advertises optional protocol features. It is appended to every
// challenge, including the rotation after a failed login, because the browser // challenge, including the rotation after a failed login, because the browser
// overwrites its capability state on each challenge. Old frontends ignore it. // 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 // 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 // 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 // connection no longer holds a subscription for it. The browser does the
// same with its local refcount. Without this, a stale entry would keep // same with its local refcount. Without this, a stale entry would keep
// countWatchers non-zero and later block the screen teardown. // countWatchers non-zero and later block the screen teardown.
type viewer struct {
c *wsClient
start time.Time
}
var stopped []viewer
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients { for c := range h.clients {
if !c.isWatching(id) { if !c.isWatching(id) {
continue continue
@@ -297,6 +330,19 @@ func (h *wsHub) OnDeviceOffline(id string) {
if c.token != "" { if c.token != "" {
c.queue(msg) 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, "ok": true,
"reason": reason, "reason": reason,
}) })
type closed struct {
c *wsClient
start time.Time
why string
}
var done []closed
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients { for c := range h.clients {
if c.termWatching == deviceID && c.token != "" { if c.termWatching == deviceID && c.token != "" {
why := c.termCloseReason
if why == "" {
why = reason
}
done = append(done, closed{c, c.termStartedAt, why})
c.termWatching = "" c.termWatching = ""
c.termStartedAt = time.Time{}
c.termCloseReason = ""
c.queue(msg) 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 // 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 // below must run OUTSIDE the lock: countWatchers takes h.mu.RLock and
// deferredCloseScreen takes h.mu.Lock. // deferredCloseScreen takes h.mu.Lock.
watched := make([]string, 0, len(c.watchIDs)) watched := make([]string, 0, len(c.watchIDs))
started := make(map[string]time.Time, len(c.watchIDs))
for id := range c.watchIDs { for id := range c.watchIDs {
watched = append(watched, id) watched = append(watched, id)
started[id] = c.watchStartedAt[id]
} }
c.watchIDs = nil c.watchIDs = nil
c.watchStartedAt = nil
c.keyframeAt = nil c.keyframeAt = nil
termDevice := c.termWatching
termStart := c.termStartedAt
c.termWatching = ""
c.termStartedAt = time.Time{}
c.termCloseReason = ""
h.mu.Unlock() h.mu.Unlock()
// If this client was the last viewer of a device, tear down the screen // If this client was the last viewer of a device, tear down the screen
// session so the device stops encoding. // session so the device stops encoding.
for _, id := range watched { 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 { if h.countWatchers(id) == 0 {
h.deferredCloseScreen(id) h.deferredCloseScreen(id)
} }
} }
// Terminal sessions are single-viewer by design, so any open session // Terminal sessions are single-viewer by design, so any open session
// belongs to this client. Tear it down so the next viewer doesn't // belongs to this client. Tear it down so the next viewer doesn't
// hit ErrTerminalBusy from an abandoned session. // hit ErrTerminalBusy from an abandoned session. The client is already
if c.termWatching != "" { // out of h.clients, so OnTerminalClosed won't reach it — audit here.
h.devices.CloseTerminalSession(c.termWatching) if termDevice != "" {
c.termWatching = "" 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. // Do NOT revoke the token: tokens are session-scoped, not WS-scoped.
// Frontend may close+reopen the WS at any time (visibilitychange handler, // Frontend may close+reopen the WS at any time (visibilitychange handler,
// brief network blip, reload) and must be able to resume with the same // 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 ------------------------------------------------------- // ----- HTTP handler -------------------------------------------------------
func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) { 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 // Build a per-call upgrader so CheckOrigin closes over this hub's
// allowlist instead of a package-level mutable. // allowlist instead of a package-level mutable.
upgrader := baseUpgrader upgrader := baseUpgrader
@@ -528,7 +628,10 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != 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 return
} }
conn.SetReadLimit(wsReadLimit) conn.SetReadLimit(wsReadLimit)
@@ -544,12 +647,19 @@ func (h *wsHub) serve(w http.ResponseWriter, r *http.Request) {
conn: conn, conn: conn,
send: make(chan wsMsg, wsSendBuffer), send: make(chan wsMsg, wsSendBuffer),
closed: make(chan struct{}), closed: make(chan struct{}),
nonce: nonce, id: newConnID(),
addr: clientIP(r, h.trustForwardedFor), 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{}), watchIDs: make(map[string]struct{}),
watchStartedAt: make(map[string]time.Time),
keyframeAt: make(map[string]int64), keyframeAt: make(map[string]int64),
} }
h.register(client) h.register(client)
h.audit(client, false, auditWSConnect, client.wsRequestFields())
defer h.unregister(client) defer h.unregister(client)
go h.writeLoop(client) go h.writeLoop(client)
+304 -21
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"sort" "sort"
"strings"
"time" "time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" "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 6 adds: term_open / term_input / term_resize / term_close (PTY relay).
// Phase 7 covers admin: create_user / delete_user / list_users / get_groups. // Phase 7 covers admin: create_user / delete_user / list_users / get_groups.
func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) { 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 { switch cmd {
case "get_salt": case "get_salt":
h.handleGetSalt(c, raw) h.handleGetSalt(c, raw)
@@ -93,6 +97,19 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
h.handleListUsers(c, raw) h.handleListUsers(c, raw)
case "get_groups": case "get_groups":
h.handleGetGroups(c, raw) 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. // c.role is cached on first successful requireAuth; safe to read here.
if c.role != "admin" { 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{ c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": "Permission denied", "cmd": replyCmd, "ok": false, "msg": "Permission denied",
})) }))
@@ -133,7 +155,21 @@ func (h *wsHub) handleGetSalt(c *wsClient, raw []byte) {
} }
_ = json.Unmarshal(raw, &in) _ = 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 // GetSalt now returns a deterministic fake salt (16 hex chars) for
// unknown users — same shape as a real salt — so an attacker can't // unknown users — same shape as a real salt — so an attacker can't
// tell from this response alone whether the username exists. // 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"` Nonce string `json:"nonce"`
} }
if err := json.Unmarshal(raw, &in); err != nil { 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"})) c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid request"}))
return return
} }
@@ -161,7 +199,11 @@ func (h *wsHub) handleLogin(c *wsClient, raw []byte) {
// known account (admin). Either dimension tripping rejects the call // known account (admin). Either dimension tripping rejects the call
// with a uniform "credentials" error so the limit is not detectable. // with a uniform "credentials" error so the limit is not detectable.
if !h.allowLoginByIP(c) || !h.allowLoginByUsername(in.Username) { 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) time.Sleep(500 * time.Millisecond)
c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"})) c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid credentials"}))
// Rotate the challenge: burns the previous nonce (replay protection) // 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 // Bind the response to the challenge we issued at connect time so that
// replays from a different connection can't reuse a captured response. // replays from a different connection can't reuse a captured response.
if in.Nonce == "" || in.Nonce != c.nonce { 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"})) c.queue(mustJSON(map[string]any{"cmd": "login_result", "ok": false, "msg": "Invalid challenge"}))
h.rotateChallenge(c) h.rotateChallenge(c)
return 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) token, role, err := h.auth.VerifyLogin(in.Username, in.Response, in.Nonce)
if err != nil { 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 // Fixed delay on failure: makes online brute force impractical
// even within the rate-limit budget, and erases the timing // even within the rate-limit budget, and erases the timing
// difference between "wrong password" and "wrong nonce". // 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. // user who fat-fingered a few times doesn't stay throttled.
h.resetLoginThrottle(c, in.Username) 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.nonce = ""
c.token = token c.token = token
c.role = role 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{ c.queue(mustJSON(map[string]any{
"cmd": "login_result", "cmd": "login_result",
@@ -257,6 +324,7 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
// Take h.mu so event-handler readers (OnResolutionChange/OnScreenFrame) // Take h.mu so event-handler readers (OnResolutionChange/OnScreenFrame)
// get a consistent view of the watch set. // get a consistent view of the watch set.
var closed []string var closed []string
starts := make(map[string]time.Time)
h.mu.Lock() h.mu.Lock()
if in.MultiWatch && in.ID != "" { if in.MultiWatch && in.ID != "" {
// New frontend: drop only this device; the other wall tiles keep // 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) { if c.isWatching(in.ID) {
delete(c.watchIDs, in.ID) delete(c.watchIDs, in.ID)
delete(c.keyframeAt, in.ID) delete(c.keyframeAt, in.ID)
starts[in.ID] = c.watchStartedAt[in.ID]
delete(c.watchStartedAt, in.ID)
closed = append(closed, in.ID) closed = append(closed, in.ID)
} }
} else { } else {
@@ -271,9 +341,11 @@ func (h *wsHub) handleDisconnect(c *wsClient, raw []byte) {
// one device, so clear everything. // one device, so clear everything.
for id := range c.watchIDs { for id := range c.watchIDs {
closed = append(closed, id) closed = append(closed, id)
starts[id] = c.watchStartedAt[id]
} }
c.watchIDs = make(map[string]struct{}) c.watchIDs = make(map[string]struct{})
c.keyframeAt = make(map[string]int64) c.keyframeAt = make(map[string]int64)
c.watchStartedAt = make(map[string]time.Time)
} }
h.mu.Unlock() 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. // deferredCloseScreen takes h.mu, so it must run outside the lock above.
for _, id := range closed { 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 { if h.countWatchers(id) == 0 {
h.deferredCloseScreen(id) h.deferredCloseScreen(id)
} }
@@ -393,8 +472,10 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
} }
var stopped []string var stopped []string
var tooMany bool starts := make(map[string]time.Time)
var tooMany, already bool
h.mu.Lock() h.mu.Lock()
_, already = c.watchIDs[in.ID]
stopped, tooMany = applyConnectWatch(c, in.ID, in.MultiWatch) stopped, tooMany = applyConnectWatch(c, in.ID, in.MultiWatch)
// A pending deferred close means the previous session is still alive for // 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. // 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() t.Stop()
delete(h.screenCloseTimers, in.ID) 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() h.mu.Unlock()
if tooMany { 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"))) c.queue(mustJSON(reply(false, "Too many devices on one connection")))
return return
} }
@@ -417,6 +512,13 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
// screen sessions are torn down outside the lock; deferredCloseScreen is // screen sessions are torn down outside the lock; deferredCloseScreen is
// a no-op while another browser still watches the device. // a no-op while another browser still watches the device.
for _, id := range stopped { 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 { if h.countWatchers(id) == 0 {
h.deferredCloseScreen(id) h.deferredCloseScreen(id)
} }
@@ -429,6 +531,15 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
// next IDR (~15 s). // next IDR (~15 s).
cache := h.devices.ScreenState(in.ID) cache := h.devices.ScreenState(in.ID)
if cache.Active { 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 := reply(true, "")
m["width"] = cache.Width m["width"] = cache.Width
m["height"] = cache.Height m["height"] = cache.Height
@@ -461,16 +572,30 @@ func (h *wsHub) handleConnect(c *wsClient, raw []byte) {
h.mu.Lock() h.mu.Lock()
delete(c.watchIDs, in.ID) delete(c.watchIDs, in.ID)
delete(c.keyframeAt, in.ID) delete(c.keyframeAt, in.ID)
delete(c.watchStartedAt, in.ID)
h.mu.Unlock() h.mu.Unlock()
msg := "Device offline" msg := "Device offline"
reason := "device_offline"
if err != hub.ErrDeviceOffline { if err != hub.ErrDeviceOffline {
msg = "Failed to start screen capture" msg = "Failed to start screen capture"
reason = "start_failed"
h.log.Error("SendToDevice(%s): %v", in.ID, err) 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))) c.queue(mustJSON(reply(false, msg)))
return return
} }
h.log.Debug("[timing] COMMAND_SCREEN_SPY sent to device=%s (cold start)", in.ID) 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, ""))) c.queue(mustJSON(reply(true, "")))
} }
@@ -561,7 +686,10 @@ func (h *wsHub) handleRdpReset(c *wsClient, raw []byte) {
} }
deviceID := h.resolveWatchDevice(c, raw) deviceID := h.resolveWatchDevice(c, raw)
if deviceID == "" { 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 return
} }
// CMD_RESTORE_CONSOLE must go through the screen sub-conn — the client // 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 // the screen sub-conn (see client/ScreenManager.cpp:996). Sending on the
// main conn would silently no-op. // main conn would silently no-op.
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CmdRestoreConsole}); err != nil { 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 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) { 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) _ = json.Unmarshal(raw, &in)
if in.Token == "" { if in.Token == "" {
h.auditBadToken(c, replyCmd, "missing_token")
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false})) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
return false return false
} }
sess, err := h.auth.ValidateToken(in.Token) sess, err := h.auth.ValidateToken(in.Token)
if err != nil { if err != nil {
h.auditBadToken(c, replyCmd, "invalid_token")
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false})) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false}))
return false return false
} }
resumed := false
h.mu.Lock()
if c.token == "" { if c.token == "" {
c.token = in.Token c.token = in.Token
c.role = sess.Role 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 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 // handleMouse forwards one mouse event from the browser to the device's
// screen sub-connection as a COMMAND_SCREEN_CONTROL packet carrying a // screen sub-connection as a COMMAND_SCREEN_CONTROL packet carrying a
// single MSG64. Mirrors the C++ CWebService::HandleMouse path // 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) deviceID := h.resolveWatchDevice(c, raw)
if deviceID == "" { if deviceID == "" {
h.audit(c, true, auditClipSet, map[string]any{
"ok": false,
"reason": "no_device",
})
return return
} }
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(in.Text)) gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(in.Text))
if err != nil { 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 return
} }
payload := append([]byte{protocol.CommandScreenSetClipboard}, gbk...) payload := append([]byte{protocol.CommandScreenSetClipboard}, gbk...)
if err := h.devices.SendToScreen(deviceID, payload); err != nil { 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 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 // 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) deviceID := h.resolveWatchDevice(c, raw)
if deviceID == "" { if deviceID == "" {
h.audit(c, true, auditClipGet, map[string]any{
"ok": false,
"reason": "no_device",
})
return return
} }
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CommandScreenGetClipboard}); err != nil { 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 return
} }
h.audit(c, false, auditClipGet, map[string]any{
"device_id": deviceID,
"ok": true,
})
} }
// handleCreateUser provisions a new web account. Admin-only. Mirrors the // 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"` AllowedGroups []string `json:"allowed_groups"`
} }
if err := json.Unmarshal(raw, &in); err != nil { 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"})) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
return return
} }
@@ -851,11 +1049,22 @@ func (h *wsHub) handleCreateUser(c *wsClient, raw []byte) {
in.Role = "viewer" in.Role = "viewer"
} }
if err := h.auth.CreateUser(in.Username, in.Password, in.Role, in.AllowedGroups); err != nil { 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()})) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
return return
} }
h.log.Info("user created by %s: name=%s role=%s groups=%d", h.audit(c, false, auditUserCreate, map[string]any{
c.role, in.Username, in.Role, len(in.AllowedGroups)) "target": in.Username,
"target_role": in.Role,
"groups": len(in.AllowedGroups),
"ok": true,
})
c.queue(mustJSON(map[string]any{"cmd": replyCmd, "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"` ID string `json:"id"`
} }
if err := json.Unmarshal(raw, &in); err != nil || in.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{ c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": "Invalid device id", "cmd": replyCmd, "ok": false, "msg": "Invalid device id",
})) }))
@@ -893,7 +1106,11 @@ func (h *wsHub) handleDeleteDevice(c *wsClient, raw []byte) {
if h.history != nil { if h.history != nil {
h.history.Remove(in.ID) 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"}`) h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
c.queue(mustJSON(map[string]any{ c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": true, "id": in.ID, "mode": "uninstall", "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: // Lost the race with a disconnect between IsOnline and the send:
// fall through to removing the (now offline) history record. // fall through to removing the (now offline) history record.
if !errors.Is(err, hub.ErrDeviceOffline) { 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{ c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": err.Error(), "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) { 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{ c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "msg": "Device not found", "cmd": replyCmd, "ok": false, "msg": "Device not found",
})) }))
return 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 // Other browsers (and this one) refresh their lists; the reply below
// still gives the initiator a precise result/toast. // still gives the initiator a precise result/toast.
h.broadcastAuthenticated(`{"cmd":"devices_changed"}`) h.broadcastAuthenticated(`{"cmd":"devices_changed"}`)
@@ -937,14 +1170,27 @@ func (h *wsHub) handleDeleteUser(c *wsClient, raw []byte) {
Username string `json:"username"` Username string `json:"username"`
} }
if err := json.Unmarshal(raw, &in); err != nil { 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"})) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": "Invalid JSON"}))
return return
} }
if err := h.auth.DeleteUser(in.Username); err != nil { 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()})) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": err.Error()}))
return 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})) 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. // screen path in handleConnect.
h.mu.Lock() h.mu.Lock()
if c.termWatching != "" && c.termWatching != in.ID { if c.termWatching != "" && c.termWatching != in.ID {
active := c.termWatching
h.mu.Unlock() 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{ c.queue(mustJSON(map[string]any{
"cmd": replyCmd, "ok": false, "cmd": replyCmd, "ok": false,
"msg": "Close current terminal before opening another", "msg": "Close current terminal before opening another",
})) }))
return return
} }
newSession := c.termWatching != in.ID
c.termWatching = in.ID c.termWatching = in.ID
c.termCloseReason = ""
if newSession || c.termStartedAt.IsZero() {
c.termStartedAt = time.Now()
}
h.mu.Unlock() h.mu.Unlock()
if err := h.devices.OpenTerminalSession(in.ID); err != nil { if err := h.devices.OpenTerminalSession(in.ID); err != nil {
h.mu.Lock() h.mu.Lock()
c.termWatching = "" c.termWatching = ""
c.termStartedAt = time.Time{}
h.mu.Unlock() h.mu.Unlock()
msg := "Device offline" msg := "Device offline"
reason := "device_offline"
switch err { switch err {
case hub.ErrTerminalBusy: case hub.ErrTerminalBusy:
msg = "Terminal already open by another viewer" msg = "Terminal already open by another viewer"
reason = "terminal_busy"
case hub.ErrDeviceOffline: case hub.ErrDeviceOffline:
msg = "Device offline" msg = "Device offline"
default: default:
msg = "Failed to start terminal" msg = "Failed to start terminal"
reason = "start_failed"
h.log.Error("OpenTerminalSession(%s): %v", in.ID, err) 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})) c.queue(mustJSON(map[string]any{"cmd": replyCmd, "ok": false, "msg": msg}))
return 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 // 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 == "" { if in.ID == "" || in.Data == "" {
return 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 return // someone else's session, or no session
} }
_ = h.devices.SendToTerminal(in.ID, []byte(in.Data)) _ = 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 { if in.ID == "" || in.Cols <= 0 || in.Rows <= 0 {
return return
} }
if c.termWatching != in.ID { h.mu.RLock()
watching := c.termWatching == in.ID
h.mu.RUnlock()
if !watching {
return return
} }
if !h.devices.TerminalIsPTY(in.ID) { 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 == "" { if err := json.Unmarshal(raw, &in); err != nil || in.ID == "" {
return 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 return
} }
h.devices.CloseTerminalSession(in.ID) h.devices.CloseTerminalSession(in.ID)
+369 -15
View File
@@ -374,6 +374,107 @@
.user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; } .user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; }
.user-msg.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; } .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 /* Generic confirmation modal (compact yes/no dialog, e.g. logout) —— 复用 .modal-overlay
做遮罩,自带一套紧凑布局 + 危险/取消双按钮风格。 */ 做遮罩,自带一套紧凑布局 + 危险/取消双按钮风格。 */
.confirm-modal-content { .confirm-modal-content {
@@ -1517,6 +1618,14 @@
<path d="M16 3.13a4 4 0 0 1 0 7.75"/> <path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg> </svg>
</button> </button>
<button class="icon-btn audit" id="audit-btn" onclick="openAuditPage()" title="Audit Log" aria-label="Audit Log">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/>
<line x1="8" y1="17" x2="16" y2="17"/>
</svg>
</button>
<button class="icon-btn logout" onclick="logout()" title="Logout" aria-label="Logout"> <button class="icon-btn logout" onclick="logout()" title="Logout" aria-label="Logout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
@@ -1670,6 +1779,72 @@
</div> </div>
</div> </div>
<!-- Audit Log Page (admin only) -->
<div id="audit-page" class="page">
<div class="page-header">
<div class="audit-title-wrap">
<button class="back-btn" onclick="showPage('devices-page')" title="Back to devices">&#8592;</button>
<h2>Audit Log <span id="audit-count" class="device-count">0</span></h2>
</div>
<div class="toolbar">
<button class="view-btn" onclick="queryAudit(0)" title="Reload newest records">Refresh</button>
</div>
</div>
<div class="audit-filters">
<label>From
<input type="datetime-local" id="audit-from" onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>To
<input type="datetime-local" id="audit-to" onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>Event (exact or prefix)
<input type="text" id="audit-event" list="audit-event-options" placeholder="e.g. web.* or web.login.failure"
onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<datalist id="audit-event-options"></datalist>
<label>Actor
<input type="text" id="audit-actor" placeholder="username" onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>IP
<input type="text" id="audit-ip" placeholder="203.0.113.1" onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>Device ID
<input type="text" id="audit-device" placeholder="device id" onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>Keyword
<input type="text" id="audit-keyword" placeholder="reason, target, ..." onkeydown="if(event.key==='Enter')queryAudit(0)">
</label>
<label>Limit
<select id="audit-limit">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
</label>
<label class="audit-warn">
<input type="checkbox" id="audit-warn-only"> Warnings only
</label>
<div class="audit-actions">
<button class="audit-search-btn" onclick="queryAudit(0)">Search</button>
<button class="audit-reset-btn" onclick="resetAuditFilters()">Reset</button>
</div>
</div>
<div id="audit-status" class="audit-status"></div>
<div class="audit-table-wrap">
<table class="audit-table">
<thead>
<tr>
<th>Time</th><th>Level</th><th>Event</th><th>Actor</th>
<th>Role</th><th>IP</th><th>Device</th><th>Details</th>
</tr>
</thead>
<tbody id="audit-rows"></tbody>
</table>
</div>
<div id="audit-pagination" class="pagination"></div>
</div>
<!-- User Management Modal --> <!-- User Management Modal -->
<div class="modal-overlay" id="users-modal"> <div class="modal-overlay" id="users-modal">
<div class="modal-content"> <div class="modal-content">
@@ -1781,7 +1956,7 @@
}; };
// 服务端能力协商:旧 C++ / Go 服务端没有 caps,前端隐藏 Wall。 // 服务端能力协商:旧 C++ / Go 服务端没有 caps,前端隐藏 Wall。
let serverCaps = { multiWatch: false, keyframeRequest: false, offlineDevices: false }; let serverCaps = { multiWatch: false, keyframeRequest: false, offlineDevices: false, auditLog: false };
// 离线设备开关:localStorage 只保存用户偏好,是否可用由服务端能力位决定。 // 离线设备开关:localStorage 只保存用户偏好,是否可用由服务端能力位决定。
// 关闭时只影响 Grid/List;Wall 侧边栏始终列出离线设备(灰显、不可操作)。 // 关闭时只影响 Grid/List;Wall 侧边栏始终列出离线设备(灰显、不可操作)。
@@ -2207,10 +2382,12 @@
serverCaps = { serverCaps = {
multiWatch: !!(msg.caps && msg.caps.multiWatch), multiWatch: !!(msg.caps && msg.caps.multiWatch),
keyframeRequest: !!(msg.caps && msg.caps.keyframeRequest), keyframeRequest: !!(msg.caps && msg.caps.keyframeRequest),
offlineDevices: !!(msg.caps && msg.caps.offlineDevices) offlineDevices: !!(msg.caps && msg.caps.offlineDevices),
auditLog: !!(msg.caps && msg.caps.auditLog)
}; };
updateWallAvailability(); updateWallAvailability();
applyOfflineCapability(); applyOfflineCapability();
updateAdminButtons();
console.log('Received challenge nonce; caps=', serverCaps); console.log('Received challenge nonce; caps=', serverCaps);
break; break;
case 'salt': case 'salt':
@@ -2228,24 +2405,25 @@
localStorage.setItem('token', token); localStorage.setItem('token', token);
localStorage.setItem('role', currentUserRole); localStorage.setItem('role', currentUserRole);
document.getElementById('login-error').textContent = ''; document.getElementById('login-error').textContent = '';
// Show Users button for admin only // Show Users / Audit Log buttons for admin only
const usersBtn = document.getElementById('users-btn'); updateAdminButtons();
if (currentUserRole === 'admin') {
usersBtn.classList.add('visible');
} else {
usersBtn.classList.remove('visible');
}
showPage('devices-page'); showPage('devices-page');
getDevices(); getDevices();
} else { } else {
document.getElementById('login-error').textContent = msg.msg || 'Login failed'; document.getElementById('login-error').textContent = msg.msg || 'Login failed';
} }
break; break;
case 'audit_result':
handleAuditResult(msg);
break;
case 'device_list': case 'device_list':
if (msg.ok === false) { if (msg.ok === false) {
// Token invalid (e.g., server restarted), go back to login // Token invalid (e.g., server restarted), go back to login
token = null; token = null;
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('role');
currentUserRole = 'viewer';
updateAdminButtons();
showPage('login-page'); showPage('login-page');
break; break;
} }
@@ -4202,11 +4380,13 @@
challengeNonce = ''; challengeNonce = '';
connectWebSocket(); // Reconnect immediately connectWebSocket(); // Reconnect immediately
token = null; token = null;
currentUserRole = 'viewer';
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('role');
devices = []; devices = [];
showPage('login-page'); showPage('login-page');
// Hide users button // Hide admin-only buttons
document.getElementById('users-btn').classList.remove('visible'); updateAdminButtons();
} }
// ===== 删除设备 ===== // ===== 删除设备 =====
@@ -4331,6 +4511,181 @@
} }
} }
// ===== Audit Log (admin only) =====
// Server-side handler: query_audit -> audit_result (see web/audit_query.go).
// Records are newest-first and already redacted at write time.
const AUDIT_EVENTS = [
'web.ws.connect', 'web.ws.reject', 'web.ws.close',
'web.login.success', 'web.login.failure', 'web.login.throttled',
'web.session.resume', 'web.auth.invalid_token', 'web.authz.denied',
'web.command.unknown', 'web.salt.unknown_user',
'remote.screen.start', 'remote.screen.stop', 'remote.screen.denied',
'remote.terminal.start', 'remote.terminal.stop', 'remote.terminal.denied',
'remote.clipboard.set', 'remote.clipboard.get', 'remote.rdp_reset',
'admin.user.create', 'admin.user.delete', 'admin.device.delete', 'admin.audit.query',
'device.online', 'device.offline',
'device.auth.success', 'device.auth.failure', 'device.auth.throttled',
'device.auth.heartbeat.success',
'device.connauth.rejected', 'device.subconn.foreign_ip'
];
let auditState = { offset: 0, limit: 100, total: 0, loaded: false };
// Show the admin-only toolbar buttons (Users / Audit Log). Called after
// login, SSO restore, logout and capability negotiation. The audit
// button also requires the server capability: the C++ server does not
// implement query_audit.
function updateAdminButtons() {
const admin = currentUserRole === 'admin';
const usersBtn = document.getElementById('users-btn');
if (usersBtn) usersBtn.classList.toggle('visible', admin);
const auditBtn = document.getElementById('audit-btn');
if (auditBtn) auditBtn.classList.toggle('visible', admin && !!serverCaps.auditLog);
}
function openAuditPage() {
if (currentUserRole !== 'admin' || !serverCaps.auditLog) return;
fillAuditEventOptions();
showPage('audit-page');
queryAudit(0);
}
function fillAuditEventOptions() {
const dl = document.getElementById('audit-event-options');
if (!dl || dl.children.length > 0) return;
dl.innerHTML = AUDIT_EVENTS.map(e => '<option value="' + e + '"></option>').join('');
}
// datetime-local values are in the browser's local zone; the server
// compares against RFC3339 audit timestamps, so convert explicitly.
function isoFromLocalInput(id) {
const v = document.getElementById(id).value;
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? '' : d.toISOString();
}
function queryAudit(offset) {
if (!ws || ws.readyState !== WebSocket.OPEN || !token) return;
if (currentUserRole !== 'admin') return;
const payload = {
cmd: 'query_audit',
token,
limit: parseInt(document.getElementById('audit-limit').value, 10) || 100,
offset: Math.max(0, offset | 0),
event: document.getElementById('audit-event').value.trim(),
actor: document.getElementById('audit-actor').value.trim(),
ip: document.getElementById('audit-ip').value.trim(),
device_id: document.getElementById('audit-device').value.trim(),
keyword: document.getElementById('audit-keyword').value.trim(),
warn_only: document.getElementById('audit-warn-only').checked
};
const from = isoFromLocalInput('audit-from');
if (from) payload.from = from;
const to = isoFromLocalInput('audit-to');
if (to) payload.to = to;
setAuditStatus('Querying...', false);
ws.send(JSON.stringify(payload));
}
function resetAuditFilters() {
['audit-from', 'audit-to', 'audit-event', 'audit-actor', 'audit-ip', 'audit-device', 'audit-keyword']
.forEach(id => { document.getElementById(id).value = ''; });
document.getElementById('audit-warn-only').checked = false;
queryAudit(0);
}
function setAuditStatus(text, warn) {
const el = document.getElementById('audit-status');
el.textContent = text || '';
el.className = 'audit-status' + (warn ? ' warn' : '');
}
function handleAuditResult(msg) {
if (!msg.ok) {
setAuditStatus(msg.msg || 'Query failed', true);
return;
}
auditState.total = msg.total || 0;
auditState.offset = msg.offset || 0;
auditState.limit = msg.limit || auditState.limit;
auditState.loaded = true;
renderAuditRows(msg.events || []);
renderAuditPagination();
const parts = [];
if (!msg.file_exists) {
parts.push('No audit file yet — no security events have been recorded.');
} else {
parts.push(auditState.total + ' matching record(s)');
const shown = (msg.events || []).length;
if (shown !== auditState.total) parts.push('showing ' + shown);
if (msg.file) parts.push('file: ' + msg.file);
}
if (msg.truncated) {
parts.push('only the newest ' +
Math.round((msg.scanned_bytes || 0) / 1048576) + ' MB of the file was scanned');
}
setAuditStatus(parts.join(' · '), !!msg.truncated);
}
function formatAuditTime(s) {
if (!s) return '';
const d = new Date(s);
return isNaN(d.getTime()) ? escapeHtml(s) : d.toLocaleString();
}
const AUDIT_STD_FIELDS = new Set(['time', 'level', 'event', 'actor', 'role', 'ip', 'device_id', 'audit', 'message', 'module']);
function formatAuditDetails(rec) {
return Object.keys(rec)
.filter(k => !AUDIT_STD_FIELDS.has(k))
.sort()
.map(k => {
let v = rec[k];
if (v !== null && typeof v === 'object') v = JSON.stringify(v);
return escapeHtml(k) + '=' + escapeHtml(String(v));
})
.join(' ');
}
function renderAuditRows(events) {
document.getElementById('audit-count').textContent = auditState.total;
const tbody = document.getElementById('audit-rows');
if (!events.length) {
tbody.innerHTML = '<tr><td colspan="8" class="audit-empty">No audit records match the filters</td></tr>';
return;
}
tbody.innerHTML = events.map(rec => {
const level = rec.level === 'warn' ? 'warn' : 'info';
return '<tr>' +
'<td title="' + escapeHtml(rec.time || '') + '">' + formatAuditTime(rec.time) + '</td>' +
'<td><span class="audit-level ' + level + '">' + level + '</span></td>' +
'<td>' + escapeHtml(rec.event || '') + '</td>' +
'<td>' + escapeHtml(rec.actor || '') + '</td>' +
'<td>' + escapeHtml(rec.role || '') + '</td>' +
'<td>' + escapeHtml(rec.ip || '') + '</td>' +
'<td>' + escapeHtml(rec.device_id || '') + '</td>' +
'<td class="audit-details">' + formatAuditDetails(rec) + '</td>' +
'</tr>';
}).join('');
}
function renderAuditPagination() {
const container = document.getElementById('audit-pagination');
if (auditState.total <= auditState.limit) {
container.innerHTML = '';
return;
}
const page = Math.floor(auditState.offset / auditState.limit) + 1;
const pages = Math.ceil(auditState.total / auditState.limit);
const prev = Math.max(0, auditState.offset - auditState.limit);
const next = auditState.offset + auditState.limit;
container.innerHTML =
'<button onclick="queryAudit(' + prev + ')" ' + (page <= 1 ? 'disabled' : '') + '>Prev</button>' +
'<span class="page-info">Page ' + page + ' of ' + pages + '</span>' +
'<button onclick="queryAudit(' + next + ')" ' + (page >= pages ? 'disabled' : '') + '>Next</button>';
}
function renderUsersList(users) { function renderUsersList(users) {
const container = document.getElementById('users-list'); const container = document.getElementById('users-list');
if (!users || users.length === 0) { if (!users || users.length === 0) {
@@ -6400,10 +6755,9 @@
// (not logged in, or SSO disabled) fall back to the restored // (not logged in, or SSO disabled) fall back to the restored
// session or the built-in login form. // session or the built-in login form.
trySSO().then(() => { trySSO().then(() => {
// Show Users button for admin only (final role known after SSO). // Show admin-only buttons once the final role is known (SSO or local login).
if (token && currentUserRole === 'admin') { if (!token) currentUserRole = 'viewer';
document.getElementById('users-btn').classList.add('visible'); updateAdminButtons();
}
connectWebSocket(); connectWebSocket();
}); });
}; };